From e347f887bc815730894d47a6944fe18b0324dfab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Tue, 23 Jun 2026 12:34:43 +0000 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20AWS=20Lambda=20de?= =?UTF-8?q?ployment=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add lambda.ts, a serverless entry point for running the admin panel as a single AWS Lambda behind a Function URL or API Gateway HTTP API (payload format 2.0). The handler serves both server routes and the static client assets itself, so no CDN or S3 bucket is required. - `build:lambda` bundles the handler + client into `dist/lambda/` - README documents the serverless deployment path - `GET /health` returns 200 for load-balancer health checks Co-Authored-By: Claude Opus 4.8 --- README.md | 10 +++ lambda.ts | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + tsconfig.json | 2 +- 4 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 lambda.ts diff --git a/README.md b/README.md index 508c60d7..2da92f22 100644 --- a/README.md +++ b/README.md @@ -78,3 +78,13 @@ docker run -p 3000:3000 \ -e VITE_BASE_PATH=/adminpanel \ librechat-admin-panel ``` + +### Serverless (AWS Lambda) + +The panel can also run as a single AWS Lambda behind a [Lambda Function URL](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) or an API Gateway HTTP API (payload format 2.0). The handler in `lambda.ts` serves both the server routes and the static client assets, so no CDN or S3 bucket is needed. + +```bash +bun run build:lambda # outputs dist/lambda/{index.mjs, client/} +``` + +Zip the contents of `dist/lambda/` and deploy with handler `index.handler` on a Node.js runtime. Set the same environment variables as the Docker deployment (`SESSION_SECRET`, `VITE_API_BASE_URL`, etc.). A `GET /health` route returns `200 ok` for load-balancer health checks. diff --git a/lambda.ts b/lambda.ts new file mode 100644 index 00000000..d23062e3 --- /dev/null +++ b/lambda.ts @@ -0,0 +1,186 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { join, sep } from 'node:path'; +import { gzipSync } from 'node:zlib'; + +/** + * AWS Lambda entry point for running the admin panel serverlessly behind a + * Lambda Function URL or an API Gateway HTTP API (payload format 2.0). + * + * The same Lambda also serves the static client assets (`dist/client`, bundled + * next to the handler as `client/`), so no CDN or S3 bucket is required — the + * deployed zip is self-contained. Build it with `bun run build:lambda`. + */ + +type LambdaEvent = { + rawPath: string; + rawQueryString: string; + cookies?: string[]; + headers?: Record; + requestContext: { http: { method: string } }; + body?: string; + isBase64Encoded?: boolean; +}; + +type LambdaResult = { + statusCode: number; + headers: Record; + cookies?: string[]; + body: string; + isBase64Encoded: boolean; +}; + +type FetchHandler = { default: { fetch: (request: Request) => Promise } }; + +const { default: app } = (await import('./dist/server/server.js')) as FetchHandler; + +const CLIENT_DIR = join(fileURLToPath(new URL('.', import.meta.url)), 'client'); +const NO_CACHE = 'no-cache, no-store, must-revalidate'; +const IMMUTABLE = 'public, max-age=31536000, immutable'; +const NEVER_CACHE = new Set(['/manifest.json', '/robots.txt', '/sw.js']); + +const CONTENT_TYPES: Record = { + js: 'text/javascript', + mjs: 'text/javascript', + css: 'text/css', + html: 'text/html; charset=utf-8', + json: 'application/json', + svg: 'image/svg+xml', + ico: 'image/x-icon', + png: 'image/png', + jpg: 'image/jpeg', + webp: 'image/webp', + woff: 'font/woff', + woff2: 'font/woff2', + txt: 'text/plain', + map: 'application/json', +}; + +function contentType(pathname: string): string { + const ext = pathname.split('.').pop()?.toLowerCase() ?? ''; + return CONTENT_TYPES[ext] ?? 'application/octet-stream'; +} + +function isStaticPath(pathname: string): boolean { + return ( + pathname.startsWith('/assets/') || + pathname.endsWith('.svg') || + pathname === '/favicon.ico' || + NEVER_CACHE.has(pathname) + ); +} + +function acceptsGzip(event: LambdaEvent): boolean { + return (event.headers?.['accept-encoding'] ?? '').includes('gzip'); +} + +function isCompressible(contentType: string): boolean { + return /javascript|css|json|html|svg|text|xml|manifest/.test(contentType); +} + +// Lambda response payloads are capped at 6 MB. Gzipping compressible assets +// keeps large chunks (e.g. the icons bundle) well under it and cuts transfer. +function encodeBody( + buffer: Buffer, + contentType: string, + gzip: boolean, +): { body: string; contentEncoding?: string } { + if (gzip && isCompressible(contentType) && buffer.length > 1024) { + return { body: gzipSync(buffer).toString('base64'), contentEncoding: 'gzip' }; + } + return { body: buffer.toString('base64') }; +} + +function buildRequest(event: LambdaEvent): Request { + const headers = new Headers(); + if (event.headers) { + for (const [name, value] of Object.entries(event.headers)) headers.set(name, value); + } + if (event.cookies?.length) headers.set('cookie', event.cookies.join('; ')); + + const host = headers.get('x-forwarded-host') ?? headers.get('host') ?? 'localhost'; + const proto = headers.get('x-forwarded-proto') ?? 'https'; + const query = event.rawQueryString ? `?${event.rawQueryString}` : ''; + const url = `${proto}://${host}${event.rawPath}${query}`; + + const method = event.requestContext.http.method; + const hasBody = event.body != null && method !== 'GET' && method !== 'HEAD'; + const body = hasBody + ? event.isBase64Encoded + ? Buffer.from(event.body as string, 'base64') + : event.body + : undefined; + + return new Request(url, { method, headers, body }); +} + +async function serveStatic(pathname: string, gzip: boolean): Promise { + const filePath = join(CLIENT_DIR, pathname); + if (filePath !== CLIENT_DIR && !filePath.startsWith(CLIENT_DIR + sep)) return null; + try { + const data = await readFile(filePath); + const ct = contentType(pathname); + const cache = pathname.startsWith('/assets/') ? IMMUTABLE : NEVER_CACHE.has(pathname) ? NO_CACHE : ''; + const encoded = encodeBody(data, ct, gzip); + const headers: Record = { 'content-type': ct }; + if (cache) headers['cache-control'] = cache; + if (encoded.contentEncoding) { + headers['content-encoding'] = encoded.contentEncoding; + headers['vary'] = 'accept-encoding'; + } + return { statusCode: 200, headers, body: encoded.body, isBase64Encoded: true }; + } catch { + return null; + } +} + +async function toLambdaResult(response: Response, gzip: boolean): Promise { + const headers: Record = {}; + response.headers.forEach((value, key) => { + if (key === 'set-cookie') return; + headers[key] = value; + }); + if (!headers['cache-control']) headers['cache-control'] = NO_CACHE; + + const cookies = + typeof response.headers.getSetCookie === 'function' ? response.headers.getSetCookie() : []; + + const buffer = Buffer.from(await response.arrayBuffer()); + const ct = headers['content-type'] ?? ''; + const alreadyEncoded = 'content-encoding' in headers; + const encoded = encodeBody(buffer, ct, gzip && !alreadyEncoded); + if (encoded.contentEncoding) { + headers['content-encoding'] = encoded.contentEncoding; + headers['vary'] = headers['vary'] ? `${headers['vary']}, accept-encoding` : 'accept-encoding'; + delete headers['content-length']; + } + + return { + statusCode: response.status, + headers, + cookies: cookies.length ? cookies : undefined, + body: encoded.body, + isBase64Encoded: true, + }; +} + +export async function handler(event: LambdaEvent): Promise { + if (event.rawPath === '/health') { + return { + statusCode: 200, + headers: { 'content-type': 'text/plain' }, + body: 'ok', + isBase64Encoded: false, + }; + } + + const gzip = acceptsGzip(event); + + if (isStaticPath(event.rawPath)) { + const asset = await serveStatic(event.rawPath, gzip); + if (asset) return asset; + } + + const response = await app.fetch(buildRequest(event)); + return toLambdaResult(response, gzip); +} diff --git a/package.json b/package.json index 7c4ca0bc..8976ac23 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "postinstall": "patch-package", "dev": "vite dev --port 3000", "build": "vite build", + "build:lambda": "vite build && bun build ./lambda.ts --target=node --format=esm --outfile=dist/lambda/index.mjs && rm -rf dist/lambda/client && cp -r dist/client dist/lambda/client", "start": "bun server.ts", "preview": "vite preview", "test": "vitest run", diff --git a/tsconfig.json b/tsconfig.json index 3dd5492b..7074f53b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["scripts", "server.ts", "tools"], + "exclude": ["scripts", "server.ts", "lambda.ts", "tools"], "compilerOptions": { "target": "ES2022", "jsx": "react-jsx", From 7364204c023e48e4d12e104b57b4b093a53a4795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Tue, 23 Jun 2026 12:42:46 +0000 Subject: [PATCH 2/7] feat: support ALB Lambda targets alongside Function URL / API Gateway Detect the incoming event shape and reply in the matching format: ELB event (statusDescription + multiValueHeaders) for ALB targets, and payload format 2.0 (headers + cookies[]) for Function URLs / HTTP API. Document both deployment paths in the README, including the ALB target group's multi-value-headers requirement and 1 MB response cap. Co-Authored-By: Claude Opus 4.8 --- README.md | 14 ++++- lambda.ts | 185 +++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 161 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 2da92f22..de60d91b 100644 --- a/README.md +++ b/README.md @@ -81,10 +81,20 @@ docker run -p 3000:3000 \ ### Serverless (AWS Lambda) -The panel can also run as a single AWS Lambda behind a [Lambda Function URL](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) or an API Gateway HTTP API (payload format 2.0). The handler in `lambda.ts` serves both the server routes and the static client assets, so no CDN or S3 bucket is needed. +The panel can also run as a single AWS Lambda. The handler in `lambda.ts` detects the incoming event shape and supports both: + +- a [Lambda Function URL](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) or API Gateway HTTP API (payload format 2.0) +- an [Application Load Balancer target](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html) (`target_type = lambda`) + +The Lambda serves both the server routes and the static client assets, so no CDN or S3 bucket is needed. ```bash bun run build:lambda # outputs dist/lambda/{index.mjs, client/} ``` -Zip the contents of `dist/lambda/` and deploy with handler `index.handler` on a Node.js runtime. Set the same environment variables as the Docker deployment (`SESSION_SECRET`, `VITE_API_BASE_URL`, etc.). A `GET /health` route returns `200 ok` for load-balancer health checks. +Zip the contents of `dist/lambda/` and deploy with handler `index.handler` on a Node.js runtime. Set the same environment variables as the Docker deployment (`SESSION_SECRET`, `VITE_API_BASE_URL`, etc.). A `GET /health` route returns `200 ok` for health checks. + +> **ALB targets:** enable multi-value headers on the target group so multiple +> `Set-Cookie` headers (required for the session/PKCE flow) survive. ALB caps +> Lambda responses at 1 MB; the handler gzips compressible assets to stay under +> it. With plain-HTTP listeners, also set `SESSION_COOKIE_SECURE=false` (see above). diff --git a/lambda.ts b/lambda.ts index d23062e3..05531339 100644 --- a/lambda.ts +++ b/lambda.ts @@ -4,25 +4,54 @@ import { join, sep } from 'node:path'; import { gzipSync } from 'node:zlib'; /** - * AWS Lambda entry point for running the admin panel serverlessly behind a - * Lambda Function URL or an API Gateway HTTP API (payload format 2.0). + * AWS Lambda entry point for running the admin panel serverlessly. It supports + * both event shapes a Lambda can receive from an HTTP front end: * - * The same Lambda also serves the static client assets (`dist/client`, bundled - * next to the handler as `client/`), so no CDN or S3 bucket is required — the - * deployed zip is self-contained. Build it with `bun run build:lambda`. + * - Lambda Function URL / API Gateway HTTP API (payload format 2.0) + * - Application Load Balancer target (`target_type = lambda`) + * + * The handler detects the shape from the event and replies in the matching + * format. The same Lambda also serves the static client assets (`dist/client`, + * bundled next to the handler as `client/`), so no CDN or S3 bucket is required + * — the deployed zip is self-contained. Build it with `bun run build:lambda`. + * + * For ALB targets, enable multi-value headers on the target group so multiple + * `Set-Cookie` headers survive. */ -type LambdaEvent = { +type AlbEvent = { + requestContext: { elb: { targetGroupArn: string } }; + httpMethod: string; + path: string; + queryStringParameters?: Record; + multiValueQueryStringParameters?: Record; + headers?: Record; + multiValueHeaders?: Record; + body?: string; + isBase64Encoded?: boolean; +}; + +type HttpEvent = { + requestContext: { http: { method: string } }; rawPath: string; rawQueryString: string; cookies?: string[]; headers?: Record; - requestContext: { http: { method: string } }; body?: string; isBase64Encoded?: boolean; }; -type LambdaResult = { +type LambdaEvent = AlbEvent | HttpEvent; + +type AlbResult = { + statusCode: number; + statusDescription: string; + multiValueHeaders: Record; + body: string; + isBase64Encoded: boolean; +}; + +type HttpResult = { statusCode: number; headers: Record; cookies?: string[]; @@ -30,6 +59,15 @@ type LambdaResult = { isBase64Encoded: boolean; }; +type Result = { + status: number; + statusText?: string; + headers: Record; + cookies: string[]; + body: string; + isBase64Encoded: boolean; +}; + type FetchHandler = { default: { fetch: (request: Request) => Promise } }; const { default: app } = (await import('./dist/server/server.js')) as FetchHandler; @@ -39,6 +77,27 @@ const NO_CACHE = 'no-cache, no-store, must-revalidate'; const IMMUTABLE = 'public, max-age=31536000, immutable'; const NEVER_CACHE = new Set(['/manifest.json', '/robots.txt', '/sw.js']); +// ALB requires statusDescription to be a " " line; a bare code +// (e.g. "307") makes the ALB return 502 Bad Gateway. +const REASON_PHRASES: Record = { + 200: 'OK', + 204: 'No Content', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 307: 'Temporary Redirect', + 308: 'Permanent Redirect', + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 429: 'Too Many Requests', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', +}; + const CONTENT_TYPES: Record = { js: 'text/javascript', mjs: 'text/javascript', @@ -56,6 +115,10 @@ const CONTENT_TYPES: Record = { map: 'application/json', }; +function isAlb(event: LambdaEvent): event is AlbEvent { + return 'elb' in event.requestContext; +} + function contentType(pathname: string): string { const ext = pathname.split('.').pop()?.toLowerCase() ?? ''; return CONTENT_TYPES[ext] ?? 'application/octet-stream'; @@ -70,16 +133,13 @@ function isStaticPath(pathname: string): boolean { ); } -function acceptsGzip(event: LambdaEvent): boolean { - return (event.headers?.['accept-encoding'] ?? '').includes('gzip'); -} - function isCompressible(contentType: string): boolean { return /javascript|css|json|html|svg|text|xml|manifest/.test(contentType); } -// Lambda response payloads are capped at 6 MB. Gzipping compressible assets -// keeps large chunks (e.g. the icons bundle) well under it and cuts transfer. +// ALB Lambda targets cap the response at 1 MB (Function URLs and API Gateway +// allow ~6 MB). Gzipping compressible assets keeps large chunks (e.g. the icons +// bundle) under the cap and cuts transfer overall. function encodeBody( buffer: Buffer, contentType: string, @@ -91,19 +151,43 @@ function encodeBody( return { body: buffer.toString('base64') }; } +function albQueryString(event: AlbEvent): string { + const params = new URLSearchParams(); + if (event.multiValueQueryStringParameters) { + for (const [key, values] of Object.entries(event.multiValueQueryStringParameters)) { + for (const value of values) params.append(key, value); + } + } else if (event.queryStringParameters) { + for (const [key, value] of Object.entries(event.queryStringParameters)) params.append(key, value); + } + return params.toString(); +} + function buildRequest(event: LambdaEvent): Request { const headers = new Headers(); - if (event.headers) { - for (const [name, value] of Object.entries(event.headers)) headers.set(name, value); + if (isAlb(event)) { + if (event.multiValueHeaders) { + for (const [name, values] of Object.entries(event.multiValueHeaders)) { + for (const value of values) headers.append(name, value); + } + } else if (event.headers) { + for (const [name, value] of Object.entries(event.headers)) headers.set(name, value); + } + } else { + if (event.headers) { + for (const [name, value] of Object.entries(event.headers)) headers.set(name, value); + } + if (event.cookies?.length) headers.set('cookie', event.cookies.join('; ')); } - if (event.cookies?.length) headers.set('cookie', event.cookies.join('; ')); + + const method = isAlb(event) ? event.httpMethod : event.requestContext.http.method; + const path = isAlb(event) ? event.path : event.rawPath; + const rawQuery = isAlb(event) ? albQueryString(event) : event.rawQueryString; const host = headers.get('x-forwarded-host') ?? headers.get('host') ?? 'localhost'; const proto = headers.get('x-forwarded-proto') ?? 'https'; - const query = event.rawQueryString ? `?${event.rawQueryString}` : ''; - const url = `${proto}://${host}${event.rawPath}${query}`; + const url = `${proto}://${host}${path}${rawQuery ? `?${rawQuery}` : ''}`; - const method = event.requestContext.http.method; const hasBody = event.body != null && method !== 'GET' && method !== 'HEAD'; const body = hasBody ? event.isBase64Encoded @@ -114,7 +198,7 @@ function buildRequest(event: LambdaEvent): Request { return new Request(url, { method, headers, body }); } -async function serveStatic(pathname: string, gzip: boolean): Promise { +async function serveStatic(pathname: string, gzip: boolean): Promise { const filePath = join(CLIENT_DIR, pathname); if (filePath !== CLIENT_DIR && !filePath.startsWith(CLIENT_DIR + sep)) return null; try { @@ -128,17 +212,16 @@ async function serveStatic(pathname: string, gzip: boolean): Promise { +async function toResult(response: Response, gzip: boolean): Promise { const headers: Record = {}; response.headers.forEach((value, key) => { - if (key === 'set-cookie') return; - headers[key] = value; + if (key !== 'set-cookie') headers[key] = value; }); if (!headers['cache-control']) headers['cache-control'] = NO_CACHE; @@ -156,31 +239,61 @@ async function toLambdaResult(response: Response, gzip: boolean): Promise { - if (event.rawPath === '/health') { +async function route(path: string, request: Request, gzip: boolean): Promise { + if (path === '/health') { return { - statusCode: 200, + status: 200, headers: { 'content-type': 'text/plain' }, + cookies: [], body: 'ok', isBase64Encoded: false, }; } - const gzip = acceptsGzip(event); - - if (isStaticPath(event.rawPath)) { - const asset = await serveStatic(event.rawPath, gzip); + if (isStaticPath(path)) { + const asset = await serveStatic(path, gzip); if (asset) return asset; } - const response = await app.fetch(buildRequest(event)); - return toLambdaResult(response, gzip); + return toResult(await app.fetch(request), gzip); +} + +function toAlbResult(result: Result): AlbResult { + const multiValueHeaders: Record = {}; + for (const [name, value] of Object.entries(result.headers)) multiValueHeaders[name] = [value]; + if (result.cookies.length) multiValueHeaders['set-cookie'] = result.cookies; + return { + statusCode: result.status, + statusDescription: `${result.status} ${result.statusText || REASON_PHRASES[result.status] || 'OK'}`, + multiValueHeaders, + body: result.body, + isBase64Encoded: result.isBase64Encoded, + }; +} + +function toHttpResult(result: Result): HttpResult { + return { + statusCode: result.status, + headers: result.headers, + cookies: result.cookies.length ? result.cookies : undefined, + body: result.body, + isBase64Encoded: result.isBase64Encoded, + }; +} + +export async function handler(event: LambdaEvent): Promise { + const path = isAlb(event) ? event.path : event.rawPath; + const request = buildRequest(event); + const gzip = (request.headers.get('accept-encoding') ?? '').includes('gzip'); + const result = await route(path, request, gzip); + return isAlb(event) ? toAlbResult(result) : toHttpResult(result); } From def571c3ea7aa857f152882bd6ebbd6eb5970aee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Tue, 23 Jun 2026 12:45:19 +0000 Subject: [PATCH 3/7] fix: serve static assets under VITE_BASE_PATH in the Lambda handler Mirror server.ts: assets live on disk without the base prefix, so strip VITE_BASE_PATH before the static lookup while still handing the app the original request (its router is configured with the base path). Without this, //assets/* requests skipped the static branch and 404'd. Co-Authored-By: Claude Opus 4.8 --- lambda.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lambda.ts b/lambda.ts index 05531339..e015dc56 100644 --- a/lambda.ts +++ b/lambda.ts @@ -73,6 +73,7 @@ type FetchHandler = { default: { fetch: (request: Request) => Promise const { default: app } = (await import('./dist/server/server.js')) as FetchHandler; const CLIENT_DIR = join(fileURLToPath(new URL('.', import.meta.url)), 'client'); +const BASE_PATH = (process.env.VITE_BASE_PATH || '').replace(/\/$/, ''); const NO_CACHE = 'no-cache, no-store, must-revalidate'; const IMMUTABLE = 'public, max-age=31536000, immutable'; const NEVER_CACHE = new Set(['/manifest.json', '/robots.txt', '/sw.js']); @@ -259,8 +260,16 @@ async function route(path: string, request: Request, gzip: boolean): Promise Date: Tue, 23 Jun 2026 12:47:12 +0000 Subject: [PATCH 4/7] docs: note that Lambda static-file serving is for simplicity, not production-ideal Co-Authored-By: Claude Opus 4.8 --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index de60d91b..8471618b 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,11 @@ The panel can also run as a single AWS Lambda. The handler in `lambda.ts` detect The Lambda serves both the server routes and the static client assets, so no CDN or S3 bucket is needed. +> **Note:** Returning static files from a Lambda is not what Lambdas are designed +> for — in production you would normally put a CDN (or S3 + CloudFront) in front +> and let it serve `dist/client` directly. The handler does it inline purely for +> simplicity, so a single self-contained zip deploys with no extra infrastructure. + ```bash bun run build:lambda # outputs dist/lambda/{index.mjs, client/} ``` From 03c76529e981c4ec0c9645a70d3d13055947ea11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Tue, 23 Jun 2026 12:55:42 +0000 Subject: [PATCH 5/7] docs: clarify Prometheus /metrics is not exposed by the Lambda The pull-model /metrics endpoint is a server.ts feature; in-process counters don't fit Lambda's ephemeral instances. Narrow the env-var list to the ones that actually apply and point to CloudWatch for Lambda metrics. Co-Authored-By: Claude Opus 4.8 --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8471618b..94812666 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,12 @@ The Lambda serves both the server routes and the static client assets, so no CDN bun run build:lambda # outputs dist/lambda/{index.mjs, client/} ``` -Zip the contents of `dist/lambda/` and deploy with handler `index.handler` on a Node.js runtime. Set the same environment variables as the Docker deployment (`SESSION_SECRET`, `VITE_API_BASE_URL`, etc.). A `GET /health` route returns `200 ok` for health checks. +Zip the contents of `dist/lambda/` and deploy with handler `index.handler` on a Node.js runtime. Set the runtime environment variables used by the Docker deployment (`SESSION_SECRET`, `VITE_API_BASE_URL`, `ADMIN_SSO_*`, `SESSION_COOKIE_SECURE`, etc.). A `GET /health` route returns `200 ok` for health checks. + +> **Note:** The Prometheus `/metrics` endpoint (and `ADMIN_PANEL_METRICS_SECRET`) +> is specific to the long-running `server.ts` deployment and is **not** exposed by +> the Lambda — in-process pull-model metrics don't fit Lambda's ephemeral, horizontally +> scaled instances. Use CloudWatch (or another push-based exporter) for Lambda metrics. > **ALB targets:** enable multi-value headers on the target group so multiple > `Set-Cookie` headers (required for the session/PKCE flow) survive. ALB caps From eadbdf046e7307aa0dc932db5dfa6ab8c6ecb053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Tue, 23 Jun 2026 13:05:36 +0000 Subject: [PATCH 6/7] fix: match server.ts security headers and base-path behavior in Lambda Bring the Lambda handler to parity with the long-running server.ts: - apply security headers (CSP report-only/enforced, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS in prod) to text/html responses - only serve static assets under VITE_BASE_PATH (not at the unprefixed path) when a base path is configured - redirect the bare base path to its trailing-slash form (302) Co-Authored-By: Claude Opus 4.8 --- lambda.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/lambda.ts b/lambda.ts index e015dc56..1b4221f0 100644 --- a/lambda.ts +++ b/lambda.ts @@ -78,6 +78,37 @@ const NO_CACHE = 'no-cache, no-store, must-revalidate'; const IMMUTABLE = 'public, max-age=31536000, immutable'; const NEVER_CACHE = new Set(['/manifest.json', '/robots.txt', '/sw.js']); +// Mirrors server.ts. Shipped report-only by default because TanStack Start's SSR +// injects an inline boot script; set ADMIN_PANEL_CSP_ENFORCE=true to enforce once +// a per-request nonce is wired up. +const CSP_VALUE = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self'", + "object-src 'none'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", +].join('; '); +const CSP_HEADER_NAME = + process.env.ADMIN_PANEL_CSP_ENFORCE === 'true' + ? 'content-security-policy' + : 'content-security-policy-report-only'; + +function applySecurityHeaders(headers: Record): void { + if (!(headers['content-type'] ?? '').toLowerCase().startsWith('text/html')) return; + headers[CSP_HEADER_NAME] = CSP_VALUE; + headers['x-content-type-options'] = 'nosniff'; + headers['referrer-policy'] = 'strict-origin-when-cross-origin'; + headers['x-frame-options'] = 'DENY'; + if (process.env.NODE_ENV === 'production') { + headers['strict-transport-security'] = 'max-age=31536000; includeSubDomains'; + } +} + // ALB requires statusDescription to be a " " line; a bare code // (e.g. "307") makes the ALB return 502 Bad Gateway. const REASON_PHRASES: Record = { @@ -260,15 +291,25 @@ async function route(path: string, request: Request, gzip: boolean): Promise Date: Thu, 12 Nov 2026 14:25:14 +0000 Subject: [PATCH 7/7] Sync librechat-admin-panel 0282141 into feat/lambda-deploy-target (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ⬆️ chore: Bump LibreChat Packages to Latest & Support New Schema (#150) Update @librechat/data-schemas (0.0.56 → 0.0.74) and librechat-data-provider (0.8.509 → 0.8.524), and adapt the panel: - Audit log: accept and render the new `permission.insights_assigned` / `permission.insights_removed` actions (agent target, "View insights" subject, grantee row). Previously any such entry failed response validation and broke the whole page. - Roles: add the new `SCHEDULES` permission type (USE, CREATE). - Interface permissions: mirror upstream `RUNTIME_CONFIG_INTERFACE_FIELDS` so the bare `interface.schedules` runtime toggle is saved instead of being silently dropped, while its `use`/`create` sub-keys stay blocked. - Langfuse: drop the forward-compat schema shim now that the pinned data-provider ships the `langfuse` group. - Config: add section meta for `filters`, `permissions`, and `openapi`, plus locale keys for ~190 new schema fields and the new `read:insights` / `manage:code_environments` capabilities. - Tests: add sample overrides for new fields with stricter validation. * 🚑 fix: Stop Configuration Page Data Loss (Wave 0) (#151) * 🚑 fix: Stop Configuration Page Data Loss (Wave 0 Hotfix) 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). * 🩹 fix: Harden Hotfix Edge Cases (Error IDs, Enter Guard Docs) - Sanitize the aria-describedby ids of key/value and rename errors (entry names can contain spaces) and give each add-key input a unique error id. - Document that the tab form's Enter guard also covers portaled create dialogs, so Enter commits the field being typed instead of submitting a half-filled dialog. * 🛡️ fix: Close Hotfix Gaps on Every Write Path (Import, Partial Strips, Reverts) Write rules now apply to every write path: - One shared rule, getWriteSkipReason (src/utils/writes.ts), now drives the review, the server pre-flight and every write path: import, base saves, bulk profile saves and saveFieldProfileValueFn. Imports can no longer store Azure groups (plaintext keys) or Anthropic Vertex overrides. A save at an ancestor path that carries one of these fields is rejected. Removing a stale override is still allowed. - Langfuse (BASE_PRINCIPAL_CONFIG_SECTIONS) is skipped on generic writes as `dedicated`. mcpAppSandbox is an interim base-only section until the data-provider bump. - A PATCH reply whose stored overrides lack a section that was sent is reported as `notStored`, so partial strips are no longer called saved. - When a later batch fails, the error says how many entries were already saved. Import: - Import leaves are normalized like edits (normalizeForSave), so `allowedDomains: [""]` and blank or null values are reported as `empty` instead of being stored. - AppService alias sections are merged leaf by leaf with their canonical section, using APP_SERVICE_KEY_ALIASES, which is shared with normalizeAppServiceKeys. - A numeric `version` no longer fails validation. Review and messages: - Emptying a value that only librechat.yaml or the defaults set is now reported as `yamlValue` ("can't be cleared here") instead of "nothing to save". - Resets show the stored override as BEFORE and the value the field falls back to as REVERTS TO. This uses librechat.yaml for base (the new yamlConfig from getBaseConfigFn) and the base value for a profile. - Warning toasts have a short title and a wrapped list of paths and reasons. When nothing was written they say "Nothing was stored", and the import success banner is not shown. Keyboard: - The tab form's Enter guard only applies to inputs in its own DOM, so the portaled Create MCP server and Create endpoint dialogs submit on Enter again. Covered by a new e2e test. Cleanup: - Removed the dead normalizeImportConfig. - ConfigRecord and PatchFieldsResponse moved to src/types. - Write helpers are typed with t.SaveEntry instead of unknown. - ImportTarget is renamed to WriteTarget. - Server utils are imported through the @/utils barrel. - Fixed the prettier issues this branch introduced. * 🩹 fix: Preserve Whitespace in Saved String Values normalizeForSave trimmed every string and list item, so saving any sibling field silently rewrote whitespace-significant values such as stop sequences ("\n\nHuman:" -> "Human:", ["\n"] dropped). Only drop exactly-empty strings and list items; keep everything else verbatim. * fix: upgrade transitive seroval to 1.6.3 (#152) * fix: upgrade vulnerable js-yaml to 4.3.2 (#153) * fix: publish admin panel image under repository organization (#156) * fix: upgrade vulnerable medium severity dependencies (#157) Pin patched versions of axios, h3 (via TanStack Start 1.167.30), lodash, lodash-es, moment, mongoose, nanoid, picomatch, postcss, source-map-js, undici, vite, ws and yaml through package.json overrides, and regenerate bun.lock. Supersedes #154. Co-authored-by: Claude Fable 5.1 * fix: upgrade vulnerable low severity build dependencies (#158) Pin @babel/core 7.29.6, browserslist 4.28.7 and baseline-browser-mapping 2.11.0 through package.json overrides and regenerate bun.lock. form-data already resolves to the patched 4.0.6. Supersedes #155. Co-authored-by: Claude Fable 5.1 * ♻️ refactor: Split Configuration Modules by Owner and Land Shared Contracts (#161) Mechanical split of the configuration page's large files into owner-aligned modules, plus the cross-workstream contract types and stubs the overhaul builds on. No behavior change: the UI renders pixel-identically on every tab and all existing tests pass unchanged. Module split: - src/server/config.ts -> src/server/config/{schema,base,normalize,save,import, index}.ts. The barrel keeps every external import unchanged. - src/server/scopes.ts -> src/server/scopes/{read,resolved,write,index}.ts. - components/configuration/utils.ts -> controls.ts, edits.ts, counts.ts, import.ts and fields/kv.ts. utils.ts stays as a temporary re-export. - FieldRenderer.tsx -> renderer/{FieldRenderer,SingleField,InlineField, NestedGroup,Collections}.tsx. - sections/EndpointsRenderer.tsx -> sections/providers/* and sections/endpoints/* (shared FIELD_GROUPS in endpoints/groups.ts). - ConfigPage.tsx (~1300 -> ~290 lines) now composes hooks from src/hooks/config/ (tabs, sections, scope, layers, edits, save, import, counts), exported via @/hooks. Contracts (src/types; each has a JSDoc naming its owner): - FieldState / ProvenanceSource via useFieldState(path) and deriveFieldState (src/utils/values.ts). - ConfigChange / ChangeSummaryItem / ApplyConfigChanges request and result. - FieldMeta registry with [] / {} wildcard resolution (configuration/meta/), plus getFieldMeta and labelForPath. - useOptionSources(source). - A SettingsRow component with label/description/control/chips/actions/error slots. ConfigRow delegates to it. * ✨ feat: Overhaul the Configuration Page (Wave 1) (#162) * ♻️ refactor: Split Configuration Modules by Owner and Land Shared Contracts Mechanical split of the configuration page's large files into owner-aligned modules, plus the cross-workstream contract types and stubs the overhaul builds on. No behavior change: the UI renders pixel-identically on every tab and all existing tests pass unchanged. Module split: - src/server/config.ts -> src/server/config/{schema,base,normalize,save,import, index}.ts. The barrel keeps every external import unchanged. - src/server/scopes.ts -> src/server/scopes/{read,resolved,write,index}.ts. - components/configuration/utils.ts -> controls.ts, edits.ts, counts.ts, import.ts and fields/kv.ts. utils.ts stays as a temporary re-export. - FieldRenderer.tsx -> renderer/{FieldRenderer,SingleField,InlineField, NestedGroup,Collections}.tsx. - sections/EndpointsRenderer.tsx -> sections/providers/* and sections/endpoints/* (shared FIELD_GROUPS in endpoints/groups.ts). - ConfigPage.tsx (~1300 -> ~290 lines) now composes hooks from src/hooks/config/ (tabs, sections, scope, layers, edits, save, import, counts), exported via @/hooks. Contracts (src/types; each has a JSDoc naming its owner): - FieldState / ProvenanceSource via useFieldState(path) and deriveFieldState (src/utils/values.ts). - ConfigChange / ChangeSummaryItem / ApplyConfigChanges request and result. - FieldMeta registry with [] / {} wildcard resolution (configuration/meta/), plus getFieldMeta and labelForPath. - useOptionSources(source). - A SettingsRow component with label/description/control/chips/actions/error slots. ConfigRow delegates to it. * ✨ feat: Overhaul the Configuration Page (Wave 1) Rebuilds the schema-driven configuration editor around effective values, safe identity-based saves and a consistent settings UI. It comes from a hands-on audit on live LibreChat stacks (~290 verified findings), was built as eight parallel workstreams, and was integrated and verified on live stacks through several rounds. Values and provenance: - Every field shows its effective value and where it comes from: default, librechat.yaml, admin override, a role/group/user profile, or inherited from Base, with the full layer stack on hover. - Reset previews show what the field actually reverts to. - Counts come from YAML + overrides, so tab badges read "25 endpoints", "1 spec", "N settings set". - Profiles show inherited values instead of empty/OFF fields. - Non-config internals no longer reach the browser. Saves: - Collection edits are identity-keyed ops (custom endpoints merge by name with minimal diffs; model specs use replace semantics with an ownership banner) instead of whole-array snapshots of the merged config. - YAML-defined entries can't be deleted or renamed. - Validation (field, entry and section, with backend issues mapped back to fields) runs before any write, and post-save checks warn when a change did not take effect. - Blank, whitespace-only and dotted-key writes are blocked; whitespace in stop sequences is preserved. Controls: - Optional booleans are tri-state (Default (On/Off) / On / Off), with defaults checked against LibreChat source; selects have a "Default (...)" option. - Correct controls for unions, literals, discriminated and boolean|object variants, model lists, secrets (masked, Replace/Remove), numbers (units, min/max/step) and multiline text. - Entry cards have real header buttons, pending deletion with Undo, and stable identity-keyed ids. Schema and metadata: - Structured kinds, constraints and nested defaults. - A field-metadata registry with labels, descriptions, units, enum labels and runtime defaults; purpose-based field order. - Unknown keys are preserved and shown read-only. Page shell: - Loading skeleton and an in-layout error state with Retry. - A sticky toolbar with an in-tab row filter, "Set values only", and an Expand all / Collapse all that really expands nested groups. - Ctrl+K searches every field and entry and deep-links to the control. - URL-driven scope (?scope=role:USER), a themed navigation guard, and responsive chrome. Review and import: - A review drawer grouped by tab, section and entry, with labels, before/after values and sources, masked secrets, warnings, per-row Revert / Go to field, and blocking errors. - The import wizard previews and merges with per-key selection and a skipped-key report. Profiles: - A context bar names the profile being edited and shows its priority and status. - Base-only and YAML-only sections are gated. - User profiles show names; deleting the active profile while dirty is safe. Visual system and a11y: - One row grid and section container; no faded rows; state chips; consistent type scale. - Single accent in light and dark; focus ring token; reduced motion. - axe: zero violations on every tab and overlay in both themes. Guardrail lint rules are added. * 🧭 feat: Purpose-Built AI Providers Editor (Wave 2) (#164) * 🧭 feat: Purpose-Built AI Providers Editor (Wave 2) Purpose-built AI providers editor (WS9, AP-1…AP-10). * 🐛 fix: Keep Pending Edits Reachable When Showing Set Values Only The configured-only filter now treats a path holding an unsaved value as set: provider groups, generic rows, sections and table-of-contents entries stay shown, so a pending edit of an unset setting never disappears behind the filter. isPathSet moves to counts.ts as the single rule. * 🔒 fix: Mask Secret Values in Collapsed Provider Summaries Collapsed provider cards and groups summarized a pending API key as plain text. Secret fields (as the field renderer detects them) now show the masked placeholder; environment references stay visible. * 🐛 fix: Keep Configured Azure Groups Visible When Showing Set Values Only * 🔌 feat: Custom Endpoints List and Editor Drawer (Wave 2) (#165) * 🧭 feat: Purpose-Built AI Providers Editor (Wave 2) Purpose-built AI providers editor (WS9, AP-1…AP-10). * 🔌 feat: Custom Endpoints List and Editor Drawer (Wave 2) Custom endpoints editor (Wave 2, WS10). * 🐛 fix: Keep Pending Edits Reachable When Showing Set Values Only The configured-only filter now treats a path holding an unsaved value as set: provider groups, generic rows, sections and table-of-contents entries stay shown, so a pending edit of an unset setting never disappears behind the filter. isPathSet moves to counts.ts as the single rule. * 🔒 fix: Mask Secret Values in Collapsed Provider Summaries Collapsed provider cards and groups summarized a pending API key as plain text. Secret fields (as the field renderer detects them) now show the masked placeholder; environment references stay visible. * 🐛 fix: Restore Undone Endpoint Deletions in Their Original Order Undo inserted a removed endpoint at its loaded position in the shortened pending list, so undoing B after deleting A and B from [A, B, C] gave [C, B]. It now goes back before the first surviving endpoint that was loaded after it. * 🐛 fix: Keep Pending Inherited Endpoint Overrides Listed Under Set Values Only In a profile, the first pending edit of an inherited endpoint is not yet a stored override, so the set-values-only filter hid the row as soon as its drawer closed. Edited rows now stay listed. * 🐛 fix: Store Complete Built-In Parameter Definitions Override a built-in parameter appended only { key, default }. LibreChat expands librechat.yaml definitions at startup but serves an override's as stored, and its client replaces the built-in definition with it whole, so the parameter lost its type, component and label. The full built-in definition is now stored (optionType custom, as LibreChat expands it), and a definition that differs from its built-in only in its default still opens as a default override. * 🐛 fix: Insert Header Placeholders Into Header Values Only The headers editor remembered every focused text input, header names included, so picking a placeholder after focusing a name wrote it into the name. Key/value inputs now carry data-kv-part, only value inputs are tracked, and with no value focused the placeholder is copied instead. * 🐛 fix: Keep Configured Azure Groups Visible When Showing Set Values Only * 🧩 feat: Model Specs Editor (Wave 2) (#166) * 🧭 feat: Purpose-Built AI Providers Editor (Wave 2) Purpose-built AI providers editor (WS9, AP-1…AP-10). * 🔌 feat: Custom Endpoints List and Editor Drawer (Wave 2) Custom endpoints editor (Wave 2, WS10). * 🧩 feat: Model Specs Editor (Wave 2) Model specs editor (WS11, Wave 2) * 🐛 fix: Keep Pending Edits Reachable When Showing Set Values Only The configured-only filter now treats a path holding an unsaved value as set: provider groups, generic rows, sections and table-of-contents entries stay shown, so a pending edit of an unset setting never disappears behind the filter. isPathSet moves to counts.ts as the single rule. * 🔒 fix: Mask Secret Values in Collapsed Provider Summaries Collapsed provider cards and groups summarized a pending API key as plain text. Secret fields (as the field renderer detects them) now show the masked placeholder; environment references stay visible. * 🐛 fix: Restore Undone Endpoint Deletions in Their Original Order Undo inserted a removed endpoint at its loaded position in the shortened pending list, so undoing B after deleting A and B from [A, B, C] gave [C, B]. It now goes back before the first surviving endpoint that was loaded after it. * 🐛 fix: Keep Pending Inherited Endpoint Overrides Listed Under Set Values Only In a profile, the first pending edit of an inherited endpoint is not yet a stored override, so the set-values-only filter hid the row as soon as its drawer closed. Edited rows now stay listed. * 🐛 fix: Store Complete Built-In Parameter Definitions Override a built-in parameter appended only { key, default }. LibreChat expands librechat.yaml definitions at startup but serves an override's as stored, and its client replaces the built-in definition with it whole, so the parameter lost its type, component and label. The full built-in definition is now stored (optionType custom, as LibreChat expands it), and a definition that differs from its built-in only in its default still opens as a default override. * 🐛 fix: Insert Header Placeholders Into Header Values Only The headers editor remembered every focused text input, header names included, so picking a placeholder after focusing a name wrote it into the name. Key/value inputs now carry data-kv-part, only value inputs are tracked, and with no value focused the placeholder is copied instead. * 🐛 fix: Keep Spec Order When One Save Adds and Removes Specs Entry ops placed removes after upserts, so a new spec's index counted entries that were about to be removed: deleting A and appending X to [A, B, C] stored [B, X, C]. Removes now come first, so every index counts only the entries that stay. * 🐛 fix: Keep a New Spec's Required Fields Under Set Values Only With Set values only on, a new spec ({}) hid Basics and the empty Endpoint and Model rows, so it could not be completed. A required field that is still empty (it has a required error) now stays visible. * 🐛 fix: Block Saving Blank or Space-Padded Duplicate Spec Names Spec save issues dropped every name problem, assuming the pipeline reports them. The pipeline keys entries by the name as written, so a whitespace-only name, or names differing only by surrounding spaces, showed an inline error but left Save enabled. Only the name problems the pipeline reports itself are dropped now. * 🐛 fix: Index Only the Prompt Fields a Spec Editor Shows Spec search indexed every prompt field, so an OpenAI spec offered a System prompt result that opened no row. The editor and the search index now share specPromptKeys: the endpoint's prompt field, the greeting, the assistants' instructions on an assistants endpoint, and any other prompt field the preset already sets (which the editor now also shows, so a stale value stays removable). * 🐛 fix: Compare a Spec Rename With the Name It Was Loaded With The rename warning compared with the name captured when the Name field first mounted, which a save or scope change does not reset: after renaming A to B and saving, renaming B again showed no warning. The loaded name now comes from the pending collection edit's origin (or the current name while the list has no pending edit). * 🐛 fix: Don't Inherit Spec Fields Through a Replaced Spec List Model specs are replaced whole, but the keyed field reader fell back to the lower layer's spec of the same name, so an owned spec without a description was shown as using librechat.yaml's (or, in a profile, Base's). A layer that owns its list now stops lower values from counting as effective, configured or in the provenance stack; the same-named lower entry stays only as the revert target. * 🐛 fix: Clear the Agent ID When a Spec Leaves the Agents Endpoint Switching an agents spec to another endpoint kept agent_id in the preset, while the editor hid the Agent field and left agent_id out of the parameter rows, so the stale value could be neither seen nor removed. Leaving the agents endpoint now clears it, and an agent ID a non-agents spec still stores (e.g. from librechat.yaml) is shown as a removable row. * 🐛 fix: Keep Configured Azure Groups Visible When Showing Set Values Only * 🐛 fix: Resolve Swapped and Chained Entry Renames as One Batch * 🐛 fix: Keep Card State With a Spec When the List Is Reordered * 🐛 fix: Align the Spec Editor's Model, Parameter and Unknown Rows With Its Endpoint * 🎨 style: Format the Spec Editor's Parameter Imports * 🛠️ feat: MCP Servers Editor (Wave 2) (#167) * 🧭 feat: Purpose-Built AI Providers Editor (Wave 2) Purpose-built AI providers editor (WS9, AP-1…AP-10). * 🛠️ feat: MCP Servers Editor (Wave 2) ## MCP servers: purpose-built editor (WS12, MCP-1…MCP-14) * 🔌 feat: Custom Endpoints List and Editor Drawer (Wave 2) Custom endpoints editor (Wave 2, WS10). * 🧩 feat: Model Specs Editor (Wave 2) Model specs editor (WS11, Wave 2) * 🐛 fix: Keep Pending Edits Reachable When Showing Set Values Only The configured-only filter now treats a path holding an unsaved value as set: provider groups, generic rows, sections and table-of-contents entries stay shown, so a pending edit of an unset setting never disappears behind the filter. isPathSet moves to counts.ts as the single rule. * 🐛 fix: Harden MCP Server Auth, Rename, Duplicate and Transport Edits - An inherited requiresOAuth: false no longer locks the API key and On-behalf-of methods. - Deleting a server renamed in this session lists the saved server as pending deletion, with Undo bringing the rename back. - The review pairs a removed and an added server as a rename only when the panel recorded the rename (or the values are identical), not because they share a URL or command. - Duplicate skips masked secret previews, notes which stored secrets must be entered again, and Save requires a key for an admin-provided API key. - A transport switch waiting for confirmation is planned from the current entry and dropped when the edit session ends. * 🔒 fix: Mask Secret Values in Collapsed Provider Summaries Collapsed provider cards and groups summarized a pending API key as plain text. Secret fields (as the field renderer detects them) now show the masked placeholder; environment references stay visible. * 🐛 fix: Restore Undone Endpoint Deletions in Their Original Order Undo inserted a removed endpoint at its loaded position in the shortened pending list, so undoing B after deleting A and B from [A, B, C] gave [C, B]. It now goes back before the first surviving endpoint that was loaded after it. * 🐛 fix: Keep Pending Inherited Endpoint Overrides Listed Under Set Values Only In a profile, the first pending edit of an inherited endpoint is not yet a stored override, so the set-values-only filter hid the row as soon as its drawer closed. Edited rows now stay listed. * 🐛 fix: Store Complete Built-In Parameter Definitions Override a built-in parameter appended only { key, default }. LibreChat expands librechat.yaml definitions at startup but serves an override's as stored, and its client replaces the built-in definition with it whole, so the parameter lost its type, component and label. The full built-in definition is now stored (optionType custom, as LibreChat expands it), and a definition that differs from its built-in only in its default still opens as a default override. * 🐛 fix: Insert Header Placeholders Into Header Values Only The headers editor remembered every focused text input, header names included, so picking a placeholder after focusing a name wrote it into the name. Key/value inputs now carry data-kv-part, only value inputs are tracked, and with no value focused the placeholder is copied instead. * 🐛 fix: Keep Spec Order When One Save Adds and Removes Specs Entry ops placed removes after upserts, so a new spec's index counted entries that were about to be removed: deleting A and appending X to [A, B, C] stored [B, X, C]. Removes now come first, so every index counts only the entries that stay. * 🐛 fix: Keep a New Spec's Required Fields Under Set Values Only With Set values only on, a new spec ({}) hid Basics and the empty Endpoint and Model rows, so it could not be completed. A required field that is still empty (it has a required error) now stays visible. * 🐛 fix: Block Saving Blank or Space-Padded Duplicate Spec Names Spec save issues dropped every name problem, assuming the pipeline reports them. The pipeline keys entries by the name as written, so a whitespace-only name, or names differing only by surrounding spaces, showed an inline error but left Save enabled. Only the name problems the pipeline reports itself are dropped now. * 🐛 fix: Index Only the Prompt Fields a Spec Editor Shows Spec search indexed every prompt field, so an OpenAI spec offered a System prompt result that opened no row. The editor and the search index now share specPromptKeys: the endpoint's prompt field, the greeting, the assistants' instructions on an assistants endpoint, and any other prompt field the preset already sets (which the editor now also shows, so a stale value stays removable). * 🐛 fix: Compare a Spec Rename With the Name It Was Loaded With The rename warning compared with the name captured when the Name field first mounted, which a save or scope change does not reset: after renaming A to B and saving, renaming B again showed no warning. The loaded name now comes from the pending collection edit's origin (or the current name while the list has no pending edit). * 🐛 fix: Don't Inherit Spec Fields Through a Replaced Spec List Model specs are replaced whole, but the keyed field reader fell back to the lower layer's spec of the same name, so an owned spec without a description was shown as using librechat.yaml's (or, in a profile, Base's). A layer that owns its list now stops lower values from counting as effective, configured or in the provenance stack; the same-named lower entry stays only as the revert target. * 🐛 fix: Clear the Agent ID When a Spec Leaves the Agents Endpoint Switching an agents spec to another endpoint kept agent_id in the preset, while the editor hid the Agent field and left agent_id out of the parameter rows, so the stale value could be neither seen nor removed. Leaving the agents endpoint now clears it, and an agent ID a non-agents spec still stores (e.g. from librechat.yaml) is shown as a removable row. * 🔒 fix: Require Stored MCP Secrets Again on Rename and Drop Rename Records on Delete Renaming a saved MCP server writes it under a new name without its stored secrets (only their masked previews are loaded), and nothing blocked saving a renamed OAuth server without its client secret. A renamed server now lists the stored secrets to enter again, and Save is blocked until they are, using the page's rename records. Deleting a renamed server only cleared the card's local rename note, so the page still recorded the rename and the review paired an unrelated new server of that name with it. Deleting it now drops the rename record (onRenameEntry(from, null)), and Undo records it again. * 🐛 fix: Keep an Overridden Inherited MCP Server Visible Under Set Values Only In a profile with Set values only, choosing Override for an inherited server from Create only unlocked it, but the filter still dropped the card (no profile value yet), and its fields would have been filtered too. An unlocked server now stays listed with all of its fields, so the first override can be entered. * 🐛 fix: Pick MCP Copy Names Free in Any Letter Case copyName tested candidates case-sensitively while server names must be unique in any letter case, so duplicating Docs next to docs-copy created Docs-copy. Candidates are now compared lowercased. * 🔒 fix: Show Stored MCP Secrets as Their Masked Preview With Replace The admin API returns a stored apiKey.key or oauth.client_secret only as its preview companion, but MCP fields passed no preview to the control, so a configured credential looked like an empty password input. The row now shows the masked preview with Replace, and Cancel discards the replacement. * 🎨 style: Format MCP Secret Preview and Rename Validation Code * 🐛 fix: Keep Configured Azure Groups Visible When Showing Set Values Only * 🐛 fix: Resolve Swapped and Chained Entry Renames as One Batch * 🐛 fix: Keep Card State With a Spec When the List Is Reordered * 🐛 fix: Align the Spec Editor's Model, Parameter and Unknown Rows With Its Endpoint * 🐛 fix: Ask a Renamed MCP Server Only for Secrets Its Authentication Still Uses * 🐛 fix: Clear MCP Delete Undo State When the Edit Session Ends * 🐛 fix: Keep a Renamed MCP Server's Field Edits When Its Rename Is Reverted * 🎨 style: Format the Spec Editor's Parameter Imports * 🐛 fix: Remove a Setting Whole When a Reverted Rename Had Dropped It * ✨ feat: Features Tab Editors (Wave 2) (#168) * 🧭 feat: Purpose-Built AI Providers Editor (Wave 2) Purpose-built AI providers editor (WS9, AP-1…AP-10). * 🛠️ feat: MCP Servers Editor (Wave 2) ## MCP servers: purpose-built editor (WS12, MCP-1…MCP-14) * 🔌 feat: Custom Endpoints List and Editor Drawer (Wave 2) Custom endpoints editor (Wave 2, WS10). * 🧩 feat: Model Specs Editor (Wave 2) Model specs editor (WS11, Wave 2) * ✨ feat: Features Tab Editors (Wave 2) Purpose-built Features tab editors (WS13). * 🐛 fix: Keep Pending Edits Reachable When Showing Set Values Only The configured-only filter now treats a path holding an unsaved value as set: provider groups, generic rows, sections and table-of-contents entries stay shown, so a pending edit of an unset setting never disappears behind the filter. isPathSet moves to counts.ts as the single rule. * 🐛 fix: Harden MCP Server Auth, Rename, Duplicate and Transport Edits - An inherited requiresOAuth: false no longer locks the API key and On-behalf-of methods. - Deleting a server renamed in this session lists the saved server as pending deletion, with Undo bringing the rename back. - The review pairs a removed and an added server as a rename only when the panel recorded the rename (or the values are identical), not because they share a URL or command. - Duplicate skips masked secret previews, notes which stored secrets must be entered again, and Save requires a key for an admin-provided API key. - A transport switch waiting for confirmation is planned from the current entry and dropped when the edit session ends. * 🐛 fix: Restore Saved Memory Agent IDs and Read Memory State Correctly - Switching the memory agent back to an existing agent unhides a saved librechat.yaml ID tombstone, so the restored ID applies again. - Enable memory defaults to On once a pending edit adds the first memory setting, and reads inverted when shown read-only. - The domain list gives its row id to the text input, so the row label focuses it. * 🔒 fix: Mask Secret Values in Collapsed Provider Summaries Collapsed provider cards and groups summarized a pending API key as plain text. Secret fields (as the field renderer detects them) now show the masked placeholder; environment references stay visible. * 🐛 fix: Restore Undone Endpoint Deletions in Their Original Order Undo inserted a removed endpoint at its loaded position in the shortened pending list, so undoing B after deleting A and B from [A, B, C] gave [C, B]. It now goes back before the first surviving endpoint that was loaded after it. * 🐛 fix: Keep Pending Inherited Endpoint Overrides Listed Under Set Values Only In a profile, the first pending edit of an inherited endpoint is not yet a stored override, so the set-values-only filter hid the row as soon as its drawer closed. Edited rows now stay listed. * 🐛 fix: Store Complete Built-In Parameter Definitions Override a built-in parameter appended only { key, default }. LibreChat expands librechat.yaml definitions at startup but serves an override's as stored, and its client replaces the built-in definition with it whole, so the parameter lost its type, component and label. The full built-in definition is now stored (optionType custom, as LibreChat expands it), and a definition that differs from its built-in only in its default still opens as a default override. * 🐛 fix: Insert Header Placeholders Into Header Values Only The headers editor remembered every focused text input, header names included, so picking a placeholder after focusing a name wrote it into the name. Key/value inputs now carry data-kv-part, only value inputs are tracked, and with no value focused the placeholder is copied instead. * 🐛 fix: Keep Spec Order When One Save Adds and Removes Specs Entry ops placed removes after upserts, so a new spec's index counted entries that were about to be removed: deleting A and appending X to [A, B, C] stored [B, X, C]. Removes now come first, so every index counts only the entries that stay. * 🐛 fix: Keep a New Spec's Required Fields Under Set Values Only With Set values only on, a new spec ({}) hid Basics and the empty Endpoint and Model rows, so it could not be completed. A required field that is still empty (it has a required error) now stays visible. * 🐛 fix: Block Saving Blank or Space-Padded Duplicate Spec Names Spec save issues dropped every name problem, assuming the pipeline reports them. The pipeline keys entries by the name as written, so a whitespace-only name, or names differing only by surrounding spaces, showed an inline error but left Save enabled. Only the name problems the pipeline reports itself are dropped now. * 🐛 fix: Index Only the Prompt Fields a Spec Editor Shows Spec search indexed every prompt field, so an OpenAI spec offered a System prompt result that opened no row. The editor and the search index now share specPromptKeys: the endpoint's prompt field, the greeting, the assistants' instructions on an assistants endpoint, and any other prompt field the preset already sets (which the editor now also shows, so a stale value stays removable). * 🐛 fix: Compare a Spec Rename With the Name It Was Loaded With The rename warning compared with the name captured when the Name field first mounted, which a save or scope change does not reset: after renaming A to B and saving, renaming B again showed no warning. The loaded name now comes from the pending collection edit's origin (or the current name while the list has no pending edit). * 🐛 fix: Don't Inherit Spec Fields Through a Replaced Spec List Model specs are replaced whole, but the keyed field reader fell back to the lower layer's spec of the same name, so an owned spec without a description was shown as using librechat.yaml's (or, in a profile, Base's). A layer that owns its list now stops lower values from counting as effective, configured or in the provenance stack; the same-named lower entry stays only as the revert target. * 🐛 fix: Clear the Agent ID When a Spec Leaves the Agents Endpoint Switching an agents spec to another endpoint kept agent_id in the preset, while the editor hid the Agent field and left agent_id out of the parameter rows, so the stale value could be neither seen nor removed. Leaving the agents endpoint now clears it, and an agent ID a non-agents spec still stores (e.g. from librechat.yaml) is shown as a removable row. * 🔒 fix: Require Stored MCP Secrets Again on Rename and Drop Rename Records on Delete Renaming a saved MCP server writes it under a new name without its stored secrets (only their masked previews are loaded), and nothing blocked saving a renamed OAuth server without its client secret. A renamed server now lists the stored secrets to enter again, and Save is blocked until they are, using the page's rename records. Deleting a renamed server only cleared the card's local rename note, so the page still recorded the rename and the review paired an unrelated new server of that name with it. Deleting it now drops the rename record (onRenameEntry(from, null)), and Undo records it again. * 🐛 fix: Keep an Overridden Inherited MCP Server Visible Under Set Values Only In a profile with Set values only, choosing Override for an inherited server from Create only unlocked it, but the filter still dropped the card (no profile value yet), and its fields would have been filtered too. An unlocked server now stays listed with all of its fields, so the first override can be entered. * 🐛 fix: Pick MCP Copy Names Free in Any Letter Case copyName tested candidates case-sensitively while server names must be unique in any letter case, so duplicating Docs next to docs-copy created Docs-copy. Candidates are now compared lowercased. * 🔒 fix: Show Stored MCP Secrets as Their Masked Preview With Replace The admin API returns a stored apiKey.key or oauth.client_secret only as its preview companion, but MCP fields passed no preview to the control, so a configured credential looked like an empty password input. The row now shows the masked preview with Replace, and Cancel discards the replacement. * 🐛 fix: Keep a Stored Memory Agent ID When Switching Back Before Saving Switching to Provider and model queues removing an agent ID the edit target stores; switching back to Existing agent did not drop that removal, so Save still deleted the ID. Switching to a definition now discards the queued removals of its own keys. * 🐛 fix: Forget the Chosen Memory Agent Definition When the Edit Session Ends A definition chosen without values lived only in the component, so after Discard or a scope change the editor still showed Provider and model for an agent that no longer existed. The choice now resets with the edit session or scope, and the mode buttons stay controlled so none is pressed without a definition. * 🐛 fix: Show Every Setting of a Web Search Service in Use, Pending Ones Too A service selected for one role was dropped from Show other services by name, so the settings of its other roles (Tavily's extract URL and scraper options while it only searches) appeared nowhere. A service in use now lists those settings in its own group. Under Set values only, a service group whose only value was a pending edit was hidden because its count reads saved values; pending values now keep it. * 🐛 fix: Keep Feature Groups With Pending Edits Under Set Values Only The interface groups and their flattened objects checked only saved paths, so editing an unset setting and then turning on Set values only hid the group holding the edit. Both checks now count pending values (isPathSet). * 🐛 fix: Keep a Newly Edited Memory Agent Under Set Values Only With no saved agent, entering a provider or model and then turning on Set values only hid the whole agent group, since it checked only saved paths. It now counts pending values too. * 🐛 fix: Drop a Pending Domain List When Its Last Domain Is Removed With an allowlist from the layer beneath, replacing it in a pending edit and then confirming removal of the last domain promised the lower list would apply again, but the shared clear kept the pending list (a list the layer beneath sets is never emptied) and said the last domain could not be removed. A domain allowlist is removed whole, so clearing now discards the pending own list. * 🎨 style: Format MCP Secret Preview and Rename Validation Code * 🎨 style: Format Web Search Service Tests * 🐛 fix: Keep Configured Azure Groups Visible When Showing Set Values Only * 🐛 fix: Resolve Swapped and Chained Entry Renames as One Batch * 🐛 fix: Keep Card State With a Spec When the List Is Reordered * 🐛 fix: Align the Spec Editor's Model, Parameter and Unknown Rows With Its Endpoint * 🐛 fix: Ask a Renamed MCP Server Only for Secrets Its Authentication Still Uses * 🐛 fix: Clear MCP Delete Undo State When the Edit Session Ends * 🐛 fix: Keep a Renamed MCP Server's Field Edits When Its Rename Is Reverted * 🎨 style: Format the Spec Editor's Parameter Imports * 🐛 fix: Remove a Setting Whole When a Reverted Rename Had Dropped It * 🗂️ feat: Files & Storage and System Editors (Wave 2) (#169) * 🧭 feat: Purpose-Built AI Providers Editor (Wave 2) Purpose-built AI providers editor (WS9, AP-1…AP-10). * 🛠️ feat: MCP Servers Editor (Wave 2) ## MCP servers: purpose-built editor (WS12, MCP-1…MCP-14) * 🔌 feat: Custom Endpoints List and Editor Drawer (Wave 2) Custom endpoints editor (Wave 2, WS10). * 🧩 feat: Model Specs Editor (Wave 2) Model specs editor (WS11, Wave 2) * ✨ feat: Features Tab Editors (Wave 2) Purpose-built Features tab editors (WS13). * 🗂️ feat: Files & Storage and System Editors (Wave 2) Purpose-built editors for the Files & storage and System tabs (WS14: FX-6 to FX-13, FX-15). * 🐛 fix: Keep Pending Edits Reachable When Showing Set Values Only The configured-only filter now treats a path holding an unsaved value as set: provider groups, generic rows, sections and table-of-contents entries stay shown, so a pending edit of an unset setting never disappears behind the filter. isPathSet moves to counts.ts as the single rule. * 🐛 fix: Harden MCP Server Auth, Rename, Duplicate and Transport Edits - An inherited requiresOAuth: false no longer locks the API key and On-behalf-of methods. - Deleting a server renamed in this session lists the saved server as pending deletion, with Undo bringing the rename back. - The review pairs a removed and an added server as a rename only when the panel recorded the rename (or the values are identical), not because they share a URL or command. - Duplicate skips masked secret previews, notes which stored secrets must be entered again, and Save requires a key for an admin-provided API key. - A transport switch waiting for confirmation is planned from the current entry and dropped when the edit session ends. * 🐛 fix: Restore Saved Memory Agent IDs and Read Memory State Correctly - Switching the memory agent back to an existing agent unhides a saved librechat.yaml ID tombstone, so the restored ID applies again. - Enable memory defaults to On once a pending edit adds the first memory setting, and reads inverted when shown read-only. - The domain list gives its row id to the text input, so the row label focuses it. * 🐛 fix: Accurate Storage, Registration, Credits, Skill Sync and Rate Limit Editors - Skill files inherit the image and document backends, so moving them off either backend is confirmed, naming both. - Social sign-in providers are read from librechat.yaml (else LibreChat's default list), never from a stale Base override. - Resetting a sign-up domain restriction to a layer without one warns that registration opens. - Credits lists balance and transactions settings the card does not place (schema rows, then unknown keys). - "Set values only" leaves out unset skill sync sources and keeps rows that hold an unsaved value. - Rate limits say which number librechat.yaml sets when it sets only one of the pair. * 🔒 fix: Mask Secret Values in Collapsed Provider Summaries Collapsed provider cards and groups summarized a pending API key as plain text. Secret fields (as the field renderer detects them) now show the masked placeholder; environment references stay visible. * 🐛 fix: Restore Undone Endpoint Deletions in Their Original Order Undo inserted a removed endpoint at its loaded position in the shortened pending list, so undoing B after deleting A and B from [A, B, C] gave [C, B]. It now goes back before the first surviving endpoint that was loaded after it. * 🐛 fix: Keep Pending Inherited Endpoint Overrides Listed Under Set Values Only In a profile, the first pending edit of an inherited endpoint is not yet a stored override, so the set-values-only filter hid the row as soon as its drawer closed. Edited rows now stay listed. * 🐛 fix: Store Complete Built-In Parameter Definitions Override a built-in parameter appended only { key, default }. LibreChat expands librechat.yaml definitions at startup but serves an override's as stored, and its client replaces the built-in definition with it whole, so the parameter lost its type, component and label. The full built-in definition is now stored (optionType custom, as LibreChat expands it), and a definition that differs from its built-in only in its default still opens as a default override. * 🐛 fix: Insert Header Placeholders Into Header Values Only The headers editor remembered every focused text input, header names included, so picking a placeholder after focusing a name wrote it into the name. Key/value inputs now carry data-kv-part, only value inputs are tracked, and with no value focused the placeholder is copied instead. * 🐛 fix: Keep Spec Order When One Save Adds and Removes Specs Entry ops placed removes after upserts, so a new spec's index counted entries that were about to be removed: deleting A and appending X to [A, B, C] stored [B, X, C]. Removes now come first, so every index counts only the entries that stay. * 🐛 fix: Keep a New Spec's Required Fields Under Set Values Only With Set values only on, a new spec ({}) hid Basics and the empty Endpoint and Model rows, so it could not be completed. A required field that is still empty (it has a required error) now stays visible. * 🐛 fix: Block Saving Blank or Space-Padded Duplicate Spec Names Spec save issues dropped every name problem, assuming the pipeline reports them. The pipeline keys entries by the name as written, so a whitespace-only name, or names differing only by surrounding spaces, showed an inline error but left Save enabled. Only the name problems the pipeline reports itself are dropped now. * 🐛 fix: Index Only the Prompt Fields a Spec Editor Shows Spec search indexed every prompt field, so an OpenAI spec offered a System prompt result that opened no row. The editor and the search index now share specPromptKeys: the endpoint's prompt field, the greeting, the assistants' instructions on an assistants endpoint, and any other prompt field the preset already sets (which the editor now also shows, so a stale value stays removable). * 🐛 fix: Compare a Spec Rename With the Name It Was Loaded With The rename warning compared with the name captured when the Name field first mounted, which a save or scope change does not reset: after renaming A to B and saving, renaming B again showed no warning. The loaded name now comes from the pending collection edit's origin (or the current name while the list has no pending edit). * 🐛 fix: Don't Inherit Spec Fields Through a Replaced Spec List Model specs are replaced whole, but the keyed field reader fell back to the lower layer's spec of the same name, so an owned spec without a description was shown as using librechat.yaml's (or, in a profile, Base's). A layer that owns its list now stops lower values from counting as effective, configured or in the provenance stack; the same-named lower entry stays only as the revert target. * 🐛 fix: Clear the Agent ID When a Spec Leaves the Agents Endpoint Switching an agents spec to another endpoint kept agent_id in the preset, while the editor hid the Agent field and left agent_id out of the parameter rows, so the stale value could be neither seen nor removed. Leaving the agents endpoint now clears it, and an agent ID a non-agents spec still stores (e.g. from librechat.yaml) is shown as a removable row. * 🔒 fix: Require Stored MCP Secrets Again on Rename and Drop Rename Records on Delete Renaming a saved MCP server writes it under a new name without its stored secrets (only their masked previews are loaded), and nothing blocked saving a renamed OAuth server without its client secret. A renamed server now lists the stored secrets to enter again, and Save is blocked until they are, using the page's rename records. Deleting a renamed server only cleared the card's local rename note, so the page still recorded the rename and the review paired an unrelated new server of that name with it. Deleting it now drops the rename record (onRenameEntry(from, null)), and Undo records it again. * 🐛 fix: Keep an Overridden Inherited MCP Server Visible Under Set Values Only In a profile with Set values only, choosing Override for an inherited server from Create only unlocked it, but the filter still dropped the card (no profile value yet), and its fields would have been filtered too. An unlocked server now stays listed with all of its fields, so the first override can be entered. * 🐛 fix: Pick MCP Copy Names Free in Any Letter Case copyName tested candidates case-sensitively while server names must be unique in any letter case, so duplicating Docs next to docs-copy created Docs-copy. Candidates are now compared lowercased. * 🔒 fix: Show Stored MCP Secrets as Their Masked Preview With Replace The admin API returns a stored apiKey.key or oauth.client_secret only as its preview companion, but MCP fields passed no preview to the control, so a configured credential looked like an empty password input. The row now shows the masked preview with Replace, and Cancel discards the replacement. * 🐛 fix: Keep a Stored Memory Agent ID When Switching Back Before Saving Switching to Provider and model queues removing an agent ID the edit target stores; switching back to Existing agent did not drop that removal, so Save still deleted the ID. Switching to a definition now discards the queued removals of its own keys. * 🐛 fix: Forget the Chosen Memory Agent Definition When the Edit Session Ends A definition chosen without values lived only in the component, so after Discard or a scope change the editor still showed Provider and model for an agent that no longer existed. The choice now resets with the edit session or scope, and the mode buttons stay controlled so none is pressed without a definition. * 🐛 fix: Show Every Setting of a Web Search Service in Use, Pending Ones Too A service selected for one role was dropped from Show other services by name, so the settings of its other roles (Tavily's extract URL and scraper options while it only searches) appeared nowhere. A service in use now lists those settings in its own group. Under Set values only, a service group whose only value was a pending edit was hidden because its count reads saved values; pending values now keep it. * 🐛 fix: Keep Feature Groups With Pending Edits Under Set Values Only The interface groups and their flattened objects checked only saved paths, so editing an unset setting and then turning on Set values only hid the group holding the edit. Both checks now count pending values (isPathSet). * 🐛 fix: Keep a Newly Edited Memory Agent Under Set Values Only With no saved agent, entering a provider or model and then turning on Set values only hid the whole agent group, since it checked only saved paths. It now counts pending values too. * 🐛 fix: Drop a Pending Domain List When Its Last Domain Is Removed With an allowlist from the layer beneath, replacing it in a pending edit and then confirming removal of the last domain promised the lower list would apply again, but the shared clear kept the pending list (a list the layer beneath sets is never emptied) and said the last domain could not be removed. A domain allowlist is removed whole, so clearing now discards the pending own list. * 🐛 fix: Keep Pending Upload Limits Under Set Values Only The per-endpoint upload limits group checked only saved paths, so an entry added and then filtered with Set values only disappeared while the section stayed visible. It now counts pending values (isPathSet). * 🐛 fix: Read Rate Limits From librechat.yaml and List Unplaced Schema Fields In a profile every value inherited from Base reads as inherited, so the table labelled schema defaults as librechat.yaml and could show an ignored Base override as the active limit. The numbers and their provenance now come from the librechat.yaml layer alone, since LibreChat reads rate limits from nowhere else. Only the limiters and pair fields the table knows were rendered, so a limiter or a limiter field a newer schema adds disappeared. Schema content the table does not place now follows as read-only rows valued from librechat.yaml (YamlRows). * 🐛 fix: Show Startup-Only Registration Settings From librechat.yaml The OAuth state TTL and OpenID discovery rows used the target's field values, so a legacy override saved for them (Base's, inherited in a profile) was shown although LibreChat reads them only from librechat.yaml at startup. They now render from librechat.yaml (else the default); Remove stale override stays. * 🐛 fix: Render Storage File Types the Fixed List Does Not Know The per-type storage group rendered only STORAGE_FILE_TYPES, so a file type a newer schema adds was counted but had no control, and an unknown key a newer backend returns was hidden. Unmatched schema fields now follow the purpose-built rows as generic rows, then unknown values. * 🎨 style: Format MCP Secret Preview and Rename Validation Code * 🎨 style: Format Web Search Service Tests * 🐛 fix: Keep Configured Azure Groups Visible When Showing Set Values Only * 🐛 fix: Resolve Swapped and Chained Entry Renames as One Batch * 🐛 fix: Keep Card State With a Spec When the List Is Reordered * 🐛 fix: Align the Spec Editor's Model, Parameter and Unknown Rows With Its Endpoint * 🐛 fix: Ask a Renamed MCP Server Only for Secrets Its Authentication Still Uses * 🐛 fix: Clear MCP Delete Undo State When the Edit Session Ends * 🐛 fix: Keep a Renamed MCP Server's Field Edits When Its Rename Is Reverted * 🎨 style: Format the Spec Editor's Parameter Imports * 🐛 fix: Keep Storage Backends the Panel Does Not Know * 🐛 fix: List Only Set Rate Limiters When Showing Set Values Only * 🐛 fix: List Unknown GitHub Skill Sync Settings in the GitHub Group * 🐛 fix: List Unknown Keys of librechat.yaml-Only Objects in Their Group * 🐛 fix: Confirm an Empty MIME List Even When It Matches the List Beneath * 🐛 fix: Remove a Setting Whole When a Reverted Rename Had Dropped It * ✅ fix: Wave 2 Integration, Verification Fixes and Schema Coverage Guarantees (#170) * ✅ fix: Wave 2 Integration, Verification Fixes and Schema Coverage Guarantees Integration, verification fixes and the schema-dynamism guarantee for the Wave 2 editors. * 🧭 feat: Purpose-Built AI Providers Editor (Wave 2) Purpose-built AI providers editor (WS9, AP-1…AP-10). * 🛠️ feat: MCP Servers Editor (Wave 2) ## MCP servers: purpose-built editor (WS12, MCP-1…MCP-14) * 🔌 feat: Custom Endpoints List and Editor Drawer (Wave 2) Custom endpoints editor (Wave 2, WS10). * 🧩 feat: Model Specs Editor (Wave 2) Model specs editor (WS11, Wave 2) * ✨ feat: Features Tab Editors (Wave 2) Purpose-built Features tab editors (WS13). * 🗂️ feat: Files & Storage and System Editors (Wave 2) Purpose-built editors for the Files & storage and System tabs (WS14: FX-6 to FX-13, FX-15). * ✅ test(config): Give Full-Tab Coverage Tests an Explicit Timeout The schema-coverage tests render an entire configuration tab; they take about 2s locally but over 5s on CI runners, exceeding Vitest's default timeout. Set a shared 30s per-test timeout for these files. * 🐛 fix: Keep Pending Edits Reachable When Showing Set Values Only The configured-only filter now treats a path holding an unsaved value as set: provider groups, generic rows, sections and table-of-contents entries stay shown, so a pending edit of an unset setting never disappears behind the filter. isPathSet moves to counts.ts as the single rule. * 🐛 fix: Harden MCP Server Auth, Rename, Duplicate and Transport Edits - An inherited requiresOAuth: false no longer locks the API key and On-behalf-of methods. - Deleting a server renamed in this session lists the saved server as pending deletion, with Undo bringing the rename back. - The review pairs a removed and an added server as a rename only when the panel recorded the rename (or the values are identical), not because they share a URL or command. - Duplicate skips masked secret previews, notes which stored secrets must be entered again, and Save requires a key for an admin-provided API key. - A transport switch waiting for confirmation is planned from the current entry and dropped when the edit session ends. * 🐛 fix: Restore Saved Memory Agent IDs and Read Memory State Correctly - Switching the memory agent back to an existing agent unhides a saved librechat.yaml ID tombstone, so the restored ID applies again. - Enable memory defaults to On once a pending edit adds the first memory setting, and reads inverted when shown read-only. - The domain list gives its row id to the text input, so the row label focuses it. * 🐛 fix: Accurate Storage, Registration, Credits, Skill Sync and Rate Limit Editors - Skill files inherit the image and document backends, so moving them off either backend is confirmed, naming both. - Social sign-in providers are read from librechat.yaml (else LibreChat's default list), never from a stale Base override. - Resetting a sign-up domain restriction to a layer without one warns that registration opens. - Credits lists balance and transactions settings the card does not place (schema rows, then unknown keys). - "Set values only" leaves out unset skill sync sources and keeps rows that hold an unsaved value. - Rate limits say which number librechat.yaml sets when it sets only one of the pair. * 🐛 fix: Validate Imported MCP Servers Per Target and Explain Invalid Inputs - YAML import checks only the shape of mcpServers before a target is chosen; the preview validates each server merged onto the chosen target's servers, so a sparse override of a server only a profile defines is accepted for that profile and refused for Base. - A custom endpoint's endpoint pickers leave out the endpoint itself by the name LibreChat serves it under (Ollama is served as ollama). - Creating an MCP server with a URL that can't be parsed marks the URL invalid with a message; any other failure of the draft as a whole is named in the footer. - A row error no longer strips aria-invalid from a control whose own error replaces it. * 🔒 fix: Mask Secret Values in Collapsed Provider Summaries Collapsed provider cards and groups summarized a pending API key as plain text. Secret fields (as the field renderer detects them) now show the masked placeholder; environment references stay visible. * 🐛 fix: Restore Undone Endpoint Deletions in Their Original Order Undo inserted a removed endpoint at its loaded position in the shortened pending list, so undoing B after deleting A and B from [A, B, C] gave [C, B]. It now goes back before the first surviving endpoint that was loaded after it. * 🐛 fix: Keep Pending Inherited Endpoint Overrides Listed Under Set Values Only In a profile, the first pending edit of an inherited endpoint is not yet a stored override, so the set-values-only filter hid the row as soon as its drawer closed. Edited rows now stay listed. * 🐛 fix: Store Complete Built-In Parameter Definitions Override a built-in parameter appended only { key, default }. LibreChat expands librechat.yaml definitions at startup but serves an override's as stored, and its client replaces the built-in definition with it whole, so the parameter lost its type, component and label. The full built-in definition is now stored (optionType custom, as LibreChat expands it), and a definition that differs from its built-in only in its default still opens as a default override. * 🐛 fix: Insert Header Placeholders Into Header Values Only The headers editor remembered every focused text input, header names included, so picking a placeholder after focusing a name wrote it into the name. Key/value inputs now carry data-kv-part, only value inputs are tracked, and with no value focused the placeholder is copied instead. * 🐛 fix: Keep Spec Order When One Save Adds and Removes Specs Entry ops placed removes after upserts, so a new spec's index counted entries that were about to be removed: deleting A and appending X to [A, B, C] stored [B, X, C]. Removes now come first, so every index counts only the entries that stay. * 🐛 fix: Keep a New Spec's Required Fields Under Set Values Only With Set values only on, a new spec ({}) hid Basics and the empty Endpoint and Model rows, so it could not be completed. A required field that is still empty (it has a required error) now stays visible. * 🐛 fix: Block Saving Blank or Space-Padded Duplicate Spec Names Spec save issues dropped every name problem, assuming the pipeline reports them. The pipeline keys entries by the name as written, so a whitespace-only name, or names differing only by surrounding spaces, showed an inline error but left Save enabled. Only the name problems the pipeline reports itself are dropped now. * 🐛 fix: Index Only the Prompt Fields a Spec Editor Shows Spec search indexed every prompt field, so an OpenAI spec offered a System prompt result that opened no row. The editor and the search index now share specPromptKeys: the endpoint's prompt field, the greeting, the assistants' instructions on an assistants endpoint, and any other prompt field the preset already sets (which the editor now also shows, so a stale value stays removable). * 🐛 fix: Compare a Spec Rename With the Name It Was Loaded With The rename warning compared with the name captured when the Name field first mounted, which a save or scope change does not reset: after renaming A to B and saving, renaming B again showed no warning. The loaded name now comes from the pending collection edit's origin (or the current name while the list has no pending edit). * 🐛 fix: Don't Inherit Spec Fields Through a Replaced Spec List Model specs are replaced whole, but the keyed field reader fell back to the lower layer's spec of the same name, so an owned spec without a description was shown as using librechat.yaml's (or, in a profile, Base's). A layer that owns its list now stops lower values from counting as effective, configured or in the provenance stack; the same-named lower entry stays only as the revert target. * 🐛 fix: Clear the Agent ID When a Spec Leaves the Agents Endpoint Switching an agents spec to another endpoint kept agent_id in the preset, while the editor hid the Agent field and left agent_id out of the parameter rows, so the stale value could be neither seen nor removed. Leaving the agents endpoint now clears it, and an agent ID a non-agents spec still stores (e.g. from librechat.yaml) is shown as a removable row. * 🔒 fix: Require Stored MCP Secrets Again on Rename and Drop Rename Records on Delete Renaming a saved MCP server writes it under a new name without its stored secrets (only their masked previews are loaded), and nothing blocked saving a renamed OAuth server without its client secret. A renamed server now lists the stored secrets to enter again, and Save is blocked until they are, using the page's rename records. Deleting a renamed server only cleared the card's local rename note, so the page still recorded the rename and the review paired an unrelated new server of that name with it. Deleting it now drops the rename record (onRenameEntry(from, null)), and Undo records it again. * 🐛 fix: Keep an Overridden Inherited MCP Server Visible Under Set Values Only In a profile with Set values only, choosing Override for an inherited server from Create only unlocked it, but the filter still dropped the card (no profile value yet), and its fields would have been filtered too. An unlocked server now stays listed with all of its fields, so the first override can be entered. * 🐛 fix: Pick MCP Copy Names Free in Any Letter Case copyName tested candidates case-sensitively while server names must be unique in any letter case, so duplicating Docs next to docs-copy created Docs-copy. Candidates are now compared lowercased. * 🔒 fix: Show Stored MCP Secrets as Their Masked Preview With Replace The admin API returns a stored apiKey.key or oauth.client_secret only as its preview companion, but MCP fields passed no preview to the control, so a configured credential looked like an empty password input. The row now shows the masked preview with Replace, and Cancel discards the replacement. * 🐛 fix: Keep a Stored Memory Agent ID When Switching Back Before Saving Switching to Provider and model queues removing an agent ID the edit target stores; switching back to Existing agent did not drop that removal, so Save still deleted the ID. Switching to a definition now discards the queued removals of its own keys. * 🐛 fix: Forget the Chosen Memory Agent Definition When the Edit Session Ends A definition chosen without values lived only in the component, so after Discard or a scope change the editor still showed Provider and model for an agent that no longer existed. The choice now resets with the edit session or scope, and the mode buttons stay controlled so none is pressed without a definition. * 🐛 fix: Show Every Setting of a Web Search Service in Use, Pending Ones Too A service selected for one role was dropped from Show other services by name, so the settings of its other roles (Tavily's extract URL and scraper options while it only searches) appeared nowhere. A service in use now lists those settings in its own group. Under Set values only, a service group whose only value was a pending edit was hidden because its count reads saved values; pending values now keep it. * 🐛 fix: Keep Feature Groups With Pending Edits Under Set Values Only The interface groups and their flattened objects checked only saved paths, so editing an unset setting and then turning on Set values only hid the group holding the edit. Both checks now count pending values (isPathSet). * 🐛 fix: Keep a Newly Edited Memory Agent Under Set Values Only With no saved agent, entering a provider or model and then turning on Set values only hid the whole agent group, since it checked only saved paths. It now counts pending values too. * 🐛 fix: Drop a Pending Domain List When Its Last Domain Is Removed With an allowlist from the layer beneath, replacing it in a pending edit and then confirming removal of the last domain promised the lower list would apply again, but the shared clear kept the pending list (a list the layer beneath sets is never emptied) and said the last domain could not be removed. A domain allowlist is removed whole, so clearing now discards the pending own list. * 🐛 fix: Keep Pending Upload Limits Under Set Values Only The per-endpoint upload limits group checked only saved paths, so an entry added and then filtered with Set values only disappeared while the section stayed visible. It now counts pending values (isPathSet). * 🐛 fix: Read Rate Limits From librechat.yaml and List Unplaced Schema Fields In a profile every value inherited from Base reads as inherited, so the table labelled schema defaults as librechat.yaml and could show an ignored Base override as the active limit. The numbers and their provenance now come from the librechat.yaml layer alone, since LibreChat reads rate limits from nowhere else. Only the limiters and pair fields the table knows were rendered, so a limiter or a limiter field a newer schema adds disappeared. Schema content the table does not place now follows as read-only rows valued from librechat.yaml (YamlRows). * 🐛 fix: Show Startup-Only Registration Settings From librechat.yaml The OAuth state TTL and OpenID discovery rows used the target's field values, so a legacy override saved for them (Base's, inherited in a profile) was shown although LibreChat reads them only from librechat.yaml at startup. They now render from librechat.yaml (else the default); Remove stale override stays. * 🐛 fix: Render Storage File Types the Fixed List Does Not Know The per-type storage group rendered only STORAGE_FILE_TYPES, so a file type a newer schema adds was counted but had no control, and an unknown key a newer backend returns was hidden. Unmatched schema fields now follow the purpose-built rows as generic rows, then unknown values. * 🎨 style: Format MCP Secret Preview and Rename Validation Code * 🎨 style: Format Web Search Service Tests * 🐛 fix: Keep Configured Azure Groups Visible When Showing Set Values Only * 🐛 fix: Resolve Swapped and Chained Entry Renames as One Batch * 🐛 fix: Keep Card State With a Spec When the List Is Reordered * 🐛 fix: Align the Spec Editor's Model, Parameter and Unknown Rows With Its Endpoint * 🐛 fix: Ask a Renamed MCP Server Only for Secrets Its Authentication Still Uses * 🐛 fix: Clear MCP Delete Undo State When the Edit Session Ends * 🐛 fix: Keep a Renamed MCP Server's Field Edits When Its Rename Is Reverted * 🎨 style: Format the Spec Editor's Parameter Imports * 🐛 fix: Keep Storage Backends the Panel Does Not Know * 🐛 fix: List Only Set Rate Limiters When Showing Set Values Only * 🐛 fix: List Unknown GitHub Skill Sync Settings in the GitHub Group * 🐛 fix: List Unknown Keys of librechat.yaml-Only Objects in Their Group * 🐛 fix: Confirm an Empty MIME List Even When It Matches the List Beneath * 🐛 fix: Remove a Setting Whole When a Reverted Rename Had Dropped It * 🧭 feat: Gate Config Behaviour on the Server's Admin Config Contract and Accept Dotted Keys (#173) * 🧭 feat: Gate Config Behaviour on the Server's Admin Config Contract and Accept Dotted Keys Adds a gating layer that reads the admin config contract (GET /api/admin/config/meta) with bundled fallbacks for older servers, accepts dotted record keys where the server keeps them, and fixes a set of smaller configuration issues. * 🧩 fix: Keep User Variable Edits Sparse Beside Dotted Names and Stop Reading Dotted Keys as Secrets * 🛡️ fix: Leave Reserved Keys Out of an Imported Record Written Whole and Import the Rest * 🧾 feat: Write Reports, Profile Priority, Pre-Auth Sign-In and Per-Field Titles from the Config Contract (#174) * 🧭 feat: Gate Config Behaviour on the Server's Admin Config Contract and Accept Dotted Keys Adds a gating layer that reads the admin config contract (GET /api/admin/config/meta) with bundled fallbacks for older servers, accepts dotted record keys where the server keeps them, and fixes a set of smaller configuration issues. * 🧩 fix: Keep User Variable Edits Sparse Beside Dotted Names and Stop Reading Dotted Keys as Secrets * 🧾 feat: Write Reports, Profile Priority, Pre-Auth Sign-In and Per-Field Titles from the Config Contract Shows what a save skipped or ignored and why, maps issues by segments and params, edits profile priority and creates profiles create-only, makes the pre-auth Base fields editable, and resolves titles per field — each gated on the server contract with today's behaviour as the fallback. * 🎯 fix: Point Paired Shadows to the Set Global Field, Keep YAML-Only Paths in Profile Merges, Count Partial Reports Apart * 🛡️ fix: Leave Reserved Keys Out of an Imported Record Written Whole and Import the Rest * 🔀 feat: Save Config Edits as One Change Batch Against the Loaded Revision (#175) * 🧭 feat: Gate Config Behaviour on the Server's Admin Config Contract and Accept Dotted Keys Adds a gating layer that reads the admin config contract (GET /api/admin/config/meta) with bundled fallbacks for older servers, accepts dotted record keys where the server keeps them, and fixes a set of smaller configuration issues. * 🧩 fix: Keep User Variable Edits Sparse Beside Dotted Names and Stop Reading Dotted Keys as Secrets * 🧾 feat: Write Reports, Profile Priority, Pre-Auth Sign-In and Per-Field Titles from the Config Contract Shows what a save skipped or ignored and why, maps issues by segments and params, edits profile priority and creates profiles create-only, makes the pre-auth Base fields editable, and resolves titles per field — each gated on the server contract with today's behaviour as the fallback. * 🎯 fix: Point Paired Shadows to the Set Global Field, Keep YAML-Only Paths in Profile Merges, Count Partial Reports Apart * 🔀 feat: Save Config Edits as One Change Batch Against the Loaded Revision On servers with change batches and revisions, saves each principal's pending edits as one POST /changes batch against the loaded revision, with a changed-elsewhere flow on conflict; older servers keep the field-by-field save path. * 🛡️ fix: Leave Reserved Keys Out of an Imported Record Written Whole and Import the Rest * 🔐 feat: Secret Fields, Carried Renames, YAML MCP Hides and Credential Guards from the Config Contract (#176) * 🧭 feat: Gate Config Behaviour on the Server's Admin Config Contract and Accept Dotted Keys Adds a gating layer that reads the admin config contract (GET /api/admin/config/meta) with bundled fallbacks for older servers, accepts dotted record keys where the server keeps them, and fixes a set of smaller configuration issues. * 🧩 fix: Keep User Variable Edits Sparse Beside Dotted Names and Stop Reading Dotted Keys as Secrets * 🧾 feat: Write Reports, Profile Priority, Pre-Auth Sign-In and Per-Field Titles from the Config Contract Shows what a save skipped or ignored and why, maps issues by segments and params, edits profile priority and creates profiles create-only, makes the pre-auth Base fields editable, and resolves titles per field — each gated on the server contract with today's behaviour as the fallback. * 🎯 fix: Point Paired Shadows to the Set Global Field, Keep YAML-Only Paths in Profile Merges, Count Partial Reports Apart * 🔀 feat: Save Config Edits as One Change Batch Against the Loaded Revision On servers with change batches and revisions, saves each principal's pending edits as one POST /changes batch against the loaded revision, with a changed-elsewhere flow on conflict; older servers keep the field-by-field save path. * 🛡️ fix: Leave Reserved Keys Out of an Imported Record Written Whole and Import the Rest * 🔐 feat: Secret Fields, Carried Renames, YAML MCP Hides and Credential Guards from the Config Contract Treats MCP and Azure group credentials as write-only secret fields with previews and omit-to-keep, carries secrets through renames and duplicates, asks for a key again when its destination moves, names the profiles a Base change affects, and hides librechat.yaml MCP servers per profile, each gated on the server contract. * 🔐 fix: Restate Only Whole ${VAR} References and Wait for Reload Before Undo Rebases * 🔭 feat: Preview as a Role, Group or User, Scope Catalog Pickers and Env Status from the Config Contract (#178) * 🔭 feat: Preview as a Role, Group or User, Scope Catalog Pickers and Env Status from the Config Contract Adds a Preview drawer that shows the config a role, group, user or everyone gets, resolved by the server with what it leaves out of each document (resolvedApi) and estimated by the panel otherwise; reads provider status, model and endpoint pickers from the edited scope's catalog instead of the end-user routes (catalogApi); and says whether each ${VAR} a field references is set on the server and allowed in overrides (envStatus), each gated on the server contract. Saves, profile changes and membership edits refresh the previews, catalogs and env status. * 🔄 fix: Refresh Previews and Catalogs After a Role or Group Save That Partly Failed * 🔄 fix: Refresh After Partly Failed Role and Group Creation; Read Every Page of Roles and Groups * 🏷️ feat: Hide Custom Endpoints per Profile, Remove Inherited Keys and Carry Keys Through Renames by Name (#179) * 🏷️ feat: Hide Custom Endpoints per Profile, Remove Inherited Keys and Carry Keys Through Renames by Name Hides a librechat.yaml or Base custom endpoint per profile by name, with the model specs and allow-list names LibreChat drops along shown in the editor, the review, imports and the Preview estimate (keyedTombstones); removes inherited header and added parameter keys inside an endpoint, in profiles and in Base, with restores, own values over a removal and Revert all accounted for in the review and the save; writes removals and resets by key so a save never drops a hide under a list; and carries a renamed endpoint's stored key through a field PATCH hint (renameHints). Each is gated on the server contract, and a server without the flags keeps today's behaviour. * 🐛 fix: Reserve Names Shown Again on Save, Say When the Filter Matches No Hidden Endpoint, Warn When Hides Leave No Spec * 🛡️ fix: Refuse New Endpoint Names LibreChat Can't Remove or Hide by Key * 🛡️ fix: Refuse a First Override of an Inherited Endpoint LibreChat Can't Address by Key * 🧬 feat: Edit Azure Groups and Vertex AI, With Spec Controls and Custom Parameters Derived as LibreChat Derives Them (#180) Edits Azure OpenAI groups merged by name, with group keys as secrets, models per group, checks LibreChat runs before saving and notes on profile overrides a Base rename or removal leaves behind, and edits Vertex AI where librechat.yaml configures it, where the server derives them again (endpointRederive); reads groups as written instead of rebuilding them from groupMap, keeping that rebuild and the stale-override notice for older servers. Spec-dependent interface controls follow the specs a profile gets (specInterfaceRederive), and a spec list that selects an endpoint the profile hides is named before Save, in the editor and in imports, where LibreChat refuses it; custom endpoint parameters are written compact where LibreChat expands them (customParamsExpansion). Revert all counts Azure groups per group beside removals by key. Each is gated on the server contract, and a server without the flags keeps today's behaviour. * build(deps): bump oven/bun from 1.3.11-alpine to 1.4.3-alpine (#160) Bumps oven/bun from 1.3.11-alpine to 1.4.3-alpine. --- updated-dependencies: - dependency-name: oven/bun dependency-version: 1.4.3-alpine dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * 🔒 fix: Upgrade TanStack Start to 1.168.60 for the Server-Function Reflected XSS Advisory (#181) Moves @tanstack/react-start to 1.168.60 and @tanstack/start-server-core to 1.169.39, the first patched releases for GHSA-qx66-fv34-fjm8 (CVE-2026-102989), with the rest of the pinned TanStack router and start family at the versions that release depends on, in dependencies and overrides. Pins srvx and solid-js once so the dev server no longer mixes two srvx copies (it crashed on an aborted request) and Solid loads once. Adapts the server functions to the release's API. The release adds a CSRF check on server functions; ADMIN_PANEL_TRUSTED_ORIGINS lists public origins it should also accept behind a proxy that rewrites Host. Requests the client abandons are recorded as 499 in the HTTP metrics instead of the 500 the release answers them with, and trusted-origin entries without a scheme are reported at startup. * 🏨 fix: Forward the Tenant Header Through Sign-In and Word Strict-Isolation Refusals (#182) Every call the panel's server makes to LibreChat before the admin has a token (password sign-in, the 2FA step, the OpenID check and code exchange, and the refresh of both OpenID and password sessions) now forwards the X-Tenant-Id of the incoming request through one shared helper, as the OpenID refresh already did. With LibreChat's strict tenant isolation and a trusted tenant header, admins can sign in, finish 2FA and keep a password session past its token's expiry. Sign-in, 2FA, SSO start and the code exchange return a code the page localizes instead of server-side English. A 403 TENANT_CONTEXT_REQUIRED reads as "sign in through your organization's admin address", apart from a missing admin capability. The OpenID callback reads the error LibreChat redirects with (TENANT_CONTEXT_REQUIRED, auth_failed, pkce_store_failed, pkce_retrieval_failed) and shows the panel's own wording, with a generic message for any other value; the query's text is never shown. A query value TanStack parses as JSON no longer drops the callback onto the generic error page. LibreChat's 404 for an unknown email or a wrong password now reads "Invalid email or password." and its 422 "Email not verified." A 403 TWO_FACTOR_FEDERATED_LOGIN_BLOCKED (2FA enforced for an account an identity provider owns) reads as "This account signs in through your identity provider", and on the 2FA step returns to the password step instead of inviting a retry. The e2e mock backend mirrors those statuses, and the login spec covers the tenant and identity-provider cases. The AuthCard tests run on the fake clock so input-otp's uncleared timers cannot fire into a torn-down jsdom. * 🧹 fix: Show User-Entered Keys in the Preview, Revert Whole Rows, Name Azure Group Hides and List the Base Tombstones LibreChat Applies (#183) * 🧹 fix: Show User-Entered Keys in the Preview, Revert Whole Rows, Name Azure Group Hides and List the Base Tombstones LibreChat Applies The Preview shows a custom endpoint's or Azure group's `user_provided` key, and an MCP server's `{{VAR}}` credential, as written, where LibreChat's reads keep them readable, instead of masking them like a stored key. Revert on a review row that restores a removed key and sets a value for it in the same save, or hides a key with an edit of it pending, takes back the value edit too. Hides of Azure groups by name that another client stored, which LibreChat applies once it derives the groups (keyedTombstones and endpointRederive), are named by group and setting in the overrides list, reviewed on Unhide with the value the group gets back, and marked Hidden or Partly hidden in the groups table and drawer. Revert all to librechat.yaml lists and counts every tombstone LibreChat applies Base stores and LibreChat applies, hidden paths and Azure group hides included. Without the flags, today's behaviour is kept. * 🏷️ fix: Mark Azure Group Hides by the Name a Group Will Be Saved Under * ⬆️ chore: Upgrade TanStack Start to 1.169.4 Without Signing Admins Out (#184) * ⬆️ chore: Upgrade TanStack Start to 1.169.4 Without Signing Admins Out Moves @tanstack/react-start from 1.168.60 to 1.169.4, the current release of the first stable line (1.169), with the rest of the pinned TanStack router and start family at the versions it depends on, in dependencies and overrides. The srvx pin moves to 1.0.7 because start-plugin-core now requires srvx 1.x; h3 left the tree, so one srvx copy and one Solid copy remain. 1.169.0 removes the built-in useSession the admin session was built on. src/server/session.ts now keeps the same admin-session cookie itself: an iron seal (iron-webcrypto) of { id, createdAt, data } with SESSION_SECRET, the same HttpOnly, SameSite=Lax, Path and Secure attributes, a 7-day Max-Age reissued on every update, h3's chunking for long values, a new empty session issued on first contact, and one session shared by every reader and writer of a request. Cookies issued before the upgrade keep working, and a rollback still reads the ones issued after it. getRequestHeader now returns an empty string for a header that is present but empty, so the OAuth exchange falls back to http when X-Forwarded-Proto is empty instead of sending "://host" as the origin. h3-v2 leaves the Vite pre-bundle list. * ✅ test: Let React's Scheduler Finish Before the 2FA Teardown Test Removes the Window * 🪪 fix: Name Group Profiles Past the First 2,000 Groups and Clamp Group Search to 200 Characters (#186) Profile names come from resolvePrincipals, which walked the admin groups list in pages of 200 and stopped after 10 pages, so a profile for a group past the first 2,000 showed its ObjectId in the profile trigger, the context bar and the profile selector. Up to 10 group profiles are now read by id with GET /api/admin/groups/:id in one parallel round, without walking the list. More are looked up in the list first, and those still missing when the walk reaches its page cap are read by id, ten at a time. Only ObjectId principal ids are read (a legacy grp_ id gets no request), and a group that is gone, can't be read or fails to load keeps its id as the name. The Groups tab clamps its search to the backend's 200-character limit before it sets the field and schedules the query, and getGroupsFn clamps what it sends, so a longer paste no longer ends in "Failed to load groups" with the search field gone. The ObjectId pattern is one constant (OBJECT_ID_PATTERN), shared with the audit-entry guards. Builds on the per-id lookup and the search clamp proposed in #115. * 🛂 fix: Parse Forwarded Headers in the SSO Exchange Origin Fallback and Log the Origin on Expired Codes (#187) When the SSO code exchange reaches the panel without Origin or Referer (a proxy that strips both), the panel rebuilds the origin it forwards to LibreChat from the request. It used Host and X-Forwarded-Proto as sent: behind a proxy chain X-Forwarded-Proto holds one value per hop, which made the origin "https, http://admin.example.com", and a proxy that rewrites Host to its upstream made it "http://admin-panel:3000". LibreChat binds each exchange code to its ADMIN_PANEL_URL's origin, so it refused the code as invalid or expired either way. The fallback now uses the first trimmed X-Forwarded-Host, ahead of Host, and the first trimmed X-Forwarded-Proto (http when it is missing or blank). Origin still comes first, and the Referer step is unchanged. When LibreChat refuses a code with INVALID_OR_EXPIRED_CODE, the panel's server now logs the Origin it forwarded and notes that LibreChat binds codes to its ADMIN_PANEL_URL's origin. An origin mismatch can then be told apart from a code that expired or that another LibreChat replica holds in a cache this one does not share. Supersedes #114 by @dustinhealy, which first parsed these forwarded headers. Refs #79. * 🛂 fix: Decide Admin Access Before the Panel Renders and Fail Closed on Errors (#185) * 🛂 fix: Decide Admin Access Before the Panel Renders and Fail Closed on Errors The /_app layout read the viewer's capabilities with a component query and showed AccessDenied only once that query had succeeded. Until then it rendered the whole panel: sidebar, header and the routed page. A signed-in user without access:admin, such as one whose grant was revoked within the 60-second session revalidation window, saw the panel before the denial. When the capabilities request failed with an error the hook does not map, such as a 500, the panel stayed up for them. The /_app loader now loads the capabilities through capabilitiesQueryOptions(userId), the query useCapabilities reads, so the layout first renders with the answer. An admin gets the panel, anyone else gets AccessDenied, and a failed load shows PermissionsUnavailable with its Reload button. The AppPending shell covers the wait. A failed background refetch keeps the last answer, so an admin stays in the panel; a refetch without access:admin still takes the panel away. Admins also get their full sidebar on first paint instead of Configuration and Access appearing after load. The unused effectiveCapabilitiesOptions, which cached a different shape under the same query key, is removed. Supersedes #109 and #101 by @dustinhealy: the loader approach comes from #109 and the fail-closed states from #101. * 🛂 fix: Fetch Stale Capabilities Again Before the Panel Renders * 🛂 fix: Fail Closed When the Loader Can't Re-Verify Stale Capabilities * 🧪 fix: Serve the Admin's Effective Capabilities from the E2E Mock Backend (#190) Since #185 the /_app route loads GET /api/admin/grants/effective before the panel renders, and fails closed when it can't. e2e/mock-backend.mjs never served that route, so every signed-in page in the mock e2e run showed "Could not verify permissions". The mock now answers it for its admin tokens with every capability in SystemCapabilities, refusing other requests the way it refuses /api/admin/verify; both share one token check. * 🔑 feat: Add Google and SAML Admin Sign-In Through a Provider Registry and Keep Deep Links Through SSO (#188) * 🔑 feat: Add Google and SAML Admin Sign-In Through a Provider Registry and Keep Deep Links Through SSO The sign-in page offers one button per SSO provider LibreChat has for admins, from a registry in src/constants (SSO_PROVIDERS: OpenID, Google, SAML). ssoProvidersFn replaces checkOpenIdFn and returns { providers, ssoOnly }. OpenID still needs its /api/admin/oauth/openid/check probe. Google and SAML come from one GET /api/config sent with the request's tenant, and count only with socialLoginEnabled, since LibreChat registers their admin strategies under ALLOW_SOCIAL_LOGIN alone. A provider left out of socialLogins is not offered, as on LibreChat's own sign-in page. ADMIN_SSO_ENABLED=false still offers none. OpenID and SAML buttons take the deployer's label from /api/config. One POST ssoStartFn ({ provider, redirectTo }) builds the PKCE challenge and keeps the verifier and the sanitized in-app path (toSafeRedirect) in the admin session, instead of the redirectTo query LibreChat strips. oauthExchangeFn takes { code, provider }, stores the provider as tokenProvider, and returns and then forgets the path. One /auth/$provider/callback route replaces the OpenID-only one: an unknown provider is not found, a failure shows SsoError, and a sign-in returns to the page it started from. Both callback paths are known metrics routes. Google sessions refresh through POST /api/admin/oauth/refresh with provider: 'google' and keep the rotated refresh token. SAML sessions, which LibreChat cannot renew, are never sent to the cookie-based /api/auth/refresh. ADMIN_SSO_ONLY auto-redirects only when exactly one provider is offered, hides the password form while any is, and brings the form back when a provider cannot start or none resolves. Supersedes #36 by @dustinhealy, reusing its provider registry, its /api/config-based availability with the social-login and socialLogins gates, and its idea of carrying the deep link through the admin session. Closes #30 Closes #65 * 🔗 fix: Bind the SSO Session to the Provider That Started It and Follow the socialLogins Order * 🧪 test: Expect the SSO Buttons in LibreChat's socialLogins Order * 🧭 fix: Return from the SSO Callback Under VITE_BASE_PATH * ⏳ fix: Restore the Sign-In Card Width and Keep SSO Buttons Busy Until the Provider Loads (#196) * 🔑 feat: Add Google and SAML Admin Sign-In Through a Provider Registry and Keep Deep Links Through SSO The sign-in page offers one button per SSO provider LibreChat has for admins, from a registry in src/constants (SSO_PROVIDERS: OpenID, Google, SAML). ssoProvidersFn replaces checkOpenIdFn and returns { providers, ssoOnly }. OpenID still needs its /api/admin/oauth/openid/check probe. Google and SAML come from one GET /api/config sent with the request's tenant, and count only with socialLoginEnabled, since LibreChat registers their admin strategies under ALLOW_SOCIAL_LOGIN alone. A provider left out of socialLogins is not offered, as on LibreChat's own sign-in page. ADMIN_SSO_ENABLED=false still offers none. OpenID and SAML buttons take the deployer's label from /api/config. One POST ssoStartFn ({ provider, redirectTo }) builds the PKCE challenge and keeps the verifier and the sanitized in-app path (toSafeRedirect) in the admin session, instead of the redirectTo query LibreChat strips. oauthExchangeFn takes { code, provider }, stores the provider as tokenProvider, and returns and then forgets the path. One /auth/$provider/callback route replaces the OpenID-only one: an unknown provider is not found, a failure shows SsoError, and a sign-in returns to the page it started from. Both callback paths are known metrics routes. Google sessions refresh through POST /api/admin/oauth/refresh with provider: 'google' and keep the rotated refresh token. SAML sessions, which LibreChat cannot renew, are never sent to the cookie-based /api/auth/refresh. ADMIN_SSO_ONLY auto-redirects only when exactly one provider is offered, hides the password form while any is, and brings the form back when a provider cannot start or none resolves. Supersedes #36 by @dustinhealy, reusing its provider registry, its /api/config-based availability with the social-login and socialLogins gates, and its idea of carrying the deep link through the admin session. Closes #30 Closes #65 * 🔗 fix: Bind the SSO Session to the Provider That Started It and Follow the socialLogins Order * 🧪 test: Expect the SSO Buttons in LibreChat's socialLogins Order * ⏳ fix: Restore the Sign-In Card Width and Keep SSO Buttons Busy Until the Provider Loads click-ui 0.9.1's unlayered `.panel { width: var(--panel-width, auto) }` outranks Tailwind's layered `w-full`, so the sign-in card and the SSO-only redirect card took their content's width: 304px and 311px at 1440 instead of max-w-md's 448px. Both Panels now take Panel's `fillWidth` (an inline `--panel-width: 100%`) and keep max-w-md. startSso reset its busy state in a `finally` that ran right after `window.location.href = authUrl`, so the button went back to its idle label, enabled, while the browser was still loading the provider, and could be clicked again. Only a start that fails gives the button back now: an error result, a request that throws, or a result with an empty URL (assigning it would reload /login). The button takes click-ui's `loading` (shimmer, disabled, aria-busy); the other providers stay disabled. On an SSO-only page with one provider, that state starts as the provider, so the redirect card shows from the first render and ends the same way. A `pageshow` with `persisted` (the browser restoring the page from its back-forward cache, Back from the provider) clears it: the button is idle again, and an SSO-only page offers its provider's button instead of a spinner that never ends. Supersedes #123 by @dustinhealy, whose fillWidth, loading and pageshow approach this follows. Depends on #188. * 🧪 fix: Serve Roles, Groups, Grants and the Audit Log from the E2E Mock and Update the Access and Grants Specs (#202) * 🧪 fix: Serve the Admin's Effective Capabilities from the E2E Mock Backend Since #185 the /_app route loads GET /api/admin/grants/effective before the panel renders, and fails closed when it can't. e2e/mock-backend.mjs never served that route, so every signed-in page in the mock e2e run showed "Could not verify permissions". The mock now answers it for its admin tokens with every capability in SystemCapabilities, refusing other requests the way it refuses /api/admin/verify; both share one token check. * 🧪 fix: Serve Roles, Groups, Grants and the Audit Log from the E2E Mock and Update the Access and Grants Specs e2e/mock-backend.mjs served none of the admin API routes the Access and Grants pages read, so most of access.spec.ts and grants.spec.ts failed against it. The mock now holds in-memory fixtures (users, the ADMIN and USER roles plus Auditor, Billing and Support, the Engineering and Marketing groups, role grants and a hash-chained grant history) and answers the roles, groups, user search, grants and audit-log routes in LibreChat's response shapes. Group search is LibreChat's case-insensitive substring match on name, email and description; user search matches word prefixes. Writes change the fixtures, so create, edit and delete flows work. Path parameters are matched per route and every admin route shares the existing token check. The specs also asserted UI the panel does not have: title-cased role names, a searchbox role, kebab menus, keyboard reordering, a roles/groups type filter and user grants. They now follow the current panel, wait for filtered results instead of sleeping, and delete the roles and groups they create. Under E2E_LIVE=1 the Groups-tab tests create the groups they list and delete them afterwards, and the tests that read users or grant history only the mock has are skipped. * 🧪 fix: Name Live E2E Roles and Groups With UUIDs, Serve the Audit-Log CSV Export From the Mock and Test That a Role Rename Leaves Its Grants Behind * 🧪 fix: Clean Up Live E2E Staging on Failure, Keep Audited Writes on the Mock and Find Staged Groups Past the First Page * 🧪 fix: Serve One Audit-Log Entry by Id From the E2E Mock * 🪃 fix: Return Focus to the Opener When the Remaining Dialogs Close (#191) These dialogs open without a Radix trigger, so closing them with Escape or the close button dropped keyboard focus on : Create and Edit role, Create and Edit group, Settings, the command menu, the scope selector, the audit-log entry drawer (in each of its four states), the "Overridden for…" field cascade and the user detail dialog. Each now passes useReturnFocus as its content's onCloseAutoFocus, as the other dialogs on main already do, so focus goes back to the control that opened it. Settings opened from the user menu while the menu's focus trap was still active, so it opened with focus on and had no opener to go back to. Its menu item now closes the menu and focuses the user-menu button first, as KebabMenu does, so Settings takes focus on open and gives it back to that button on close. cmdk's Command.Dialog does not pass onCloseAutoFocus to its Radix content. A shared CommandDialog renders the same Radix dialog around and takes it; the command menu and the scope selector use it. In the command menu, Escape and commands that stay on the page give focus back to the opener, and commands that open a page leave focus to that page (a setting search still lands on the field). The cascade's "Open profile" leaves Base, which takes its link away after the dialog has given focus back to it, so it now closes the dialog and focuses the open configuration tab before it switches profile. The unsaved-changes prompt that a profile pick with unsaved edits opens has lost its opener by the time it closes, so it now falls back to that tab too, as the reset and review dialogs do. Supersedes #120 by @dustinhealy, which first wired a capture-on-open, restore-on-close hook into these dialogs. * ⌨️ fix: Make Scope Selector Buttons Operable with Enter and Highlight the Open Profile (#192) cmdk's root keydown cancels Enter and runs the highlighted option instead, so Enter on a plain button inside the scope selector never reached the button. Enter on Create picked the highlighted Base option and closed the selector, Enter on "Back to profiles" created a profile for the highlighted candidate, and Enter on the delete confirmation's Cancel and "Delete configuration" did nothing. Create and "Back to profiles" now stop Enter before it reaches the cmdk root, so Enter activates them like a click. The selector renders its own Radix dialog, and each view with a menu mounts its own cmdk root, so a highlight never carries from one view's options into the other's. The delete confirmation has no menu, so cmdk's key handling never reaches its buttons. It opens with focus on Cancel. The profile and create searches take focus when their view opens. The profile list highlights the open profile (or Base) through cmdk's controlled value, on every open and after going back from the create view, so Enter in the untouched search keeps the open profile instead of switching to Base. Cancelling a delete returns to the delete option it came from. The highlight's background now wins over the open profile's, so the open profile reads as highlighted when the selector opens. Before, both rules had the same specificity and the open profile's came last, which also hid the highlight on Base with no profile open and in the preview picker. Builds on the Enter analysis and keyboard tests proposed in #121. * 🎯 fix: Center Collapsed Sidebar Icons and Unclip Sidebar Focus Rings (#193) * 🎯 fix: Center Collapsed Sidebar Icons and Unclip Sidebar Focus Rings The collapsed rail drew the logo, nav icons and avatar at x=26, 1.5px left of the rail's inner center (27.5): the rows lay out from the left in both states, so the icons stayed where the expanded panel puts them. Collapsed rows now use gap-0 with justify-center, and the labels beside the icons (app title, nav labels, user name and email) become w-0 flex-none overflow-hidden so they take no space. The rows are also the rail's inner width (39px) from the first frame of the collapse, so the icons sit at 27.5 while the panel width animates instead of sweeping in from the middle of the shrinking panel. The avatar's focus ring (2px at a 2px offset) was cut by its row wrapper's overflow-hidden. The wrapper no longer clips; min-w-0 keeps a long name and email truncating inside the panel. The collapse toggle is flush with the panel's clipped edges and the bottom of the viewport, so only the top of its ring showed. A scoped .sidebar-toggle:focus-visible rule draws the ring inset. Supersedes #122 by @dustinhealy, whose approach this follows. * 📏 fix: Let Collapsed Rail Rows Narrow Beside a Scrolling Nav's Scrollbar * 💍 fix: Unclip and Recolor the Keyboard Highlight Ring in Select Popovers (#195) The keyboard-highlighted option in a click-ui Select popover showed its ring on three sides only. The popover is as wide as the trigger and clips its content (overflow: hidden) inside 1px borders, but the option list is also as wide as the trigger, so options overhang the clip box by 2px on the right. click-ui draws a 2px ring at a -2px offset, in the option's outer 2px, so the right side fell entirely in the overhang. The ring also used click-ui's accent, near-black in the light theme, instead of the app's focus color. A scoped rule in styles.css, next to the other focus-ring overrides, draws the ring of a keyboard-highlighted option in --focus-ring-color, 4px inside the option. data-input-modality is only set on click-ui menu panels, so the rule reaches click-ui Select, Dropdown and ContextMenu options only, and only while the keyboard drives them. Dropdown menus (the sidebar user menu, the row action menus) already showed a whole ring; they now draw the same ring as the Selects. e2e/a11y/menus.spec.ts (live backend) measures the highlighted option's ring in two Selects and the user menu, in light and dark mode: solid, in the focus color, and inside every clipping ancestor up to the menu. Supersedes #125 by @dustinhealy, whose root-cause analysis and inset ring this reuses. * 🫥 fix: Remove the Focus Line Across the Top of Select Popovers (#200) * 🫥 fix: Remove the Focus Line Across the Top of Select Popovers An open click-ui Select moves focus into its search input. Unless the Select shows its search, that input has zero height, and the app's `input:not([cmdk-input]):focus-visible` ring drew it as a 2px line in the focus color across the first option, in both themes and also when the Select was opened with the mouse. A new rule in src/styles.css removes the outline from `[data-testid='select-search-input']` unless its class contains `search-bar_show-search`. click-ui 0.9.1 and 0.15.0 render the same test id and CSS module class, so a Select that shows its search keeps its ring. The rule is separate from the input ring rule because a compound selector in that rule's `:not()` would raise its specificity above `.cui-field input:focus-visible { outline: none }` and bring back the bare input ring inside click-ui fields. e2e/a11y/select.spec.ts (live) opens the audit log Target type filter and the configuration Jump to list in light and dark. It checks that the focused, zero-height search input has no outline, then that the same input with click-ui's shown-search class has height and a 2px solid ring. The two Selects and the theme list are exported from e2e/live.ts as SELECTS and THEMES, so other a11y specs can open the same Selects without copying the setup; e2e/a11y/config.spec.ts now imports THEMES instead of declaring its own. * ♻️ refactor: Read the Menus Spec's Themes and Selects From the Shared Live Helpers * 🧪 fix: Delete the USER Profile the Config Shell Spec Creates and Unbreak the Live Profiles and Import Specs (#201) * 🧪 fix: Delete the USER Profile the Config Shell Spec Creates and Unbreak the Live Profiles and Import Specs e2e/config-shell.spec.ts (live) created the USER role's profile on a clean database to test Back from a profile and never deleted it, so later runs and other specs started from a different state. The test now records when it created the profile, and an afterEach deletes it through the scope selector, as an admin would, while it still has no overrides. A profile the test found is left alone. The selector helper waits for the profile list to load before deciding whether USER has a profile, so that decision is never made on an empty, still-loading list. After confirming the delete, the cleanup waits for the profile list to come back before checking that USER is gone: the confirm view lists no options and stays up until the delete succeeds, so checking only that the row is gone would pass at once, even when the delete failed. e2e/profiles.spec.ts: the Registration section of a user profile shows two notes, the scope gate and the read-only note for the settings LibreChat sets at startup, so `#section-registration [role=note]` was a strict-mode violation. The locator now targets the scope note (`data-read-only=scope`). e2e/import.spec.ts: ensureUserProfile counted the "Create profile" button once after networkidle, before the roles query had rendered it, so on a fresh database it skipped creating the USER profile and "imports into an existing profile" timed out. It now waits until the page shows either the open profile or the Create profile button, and creates the profile in the second case. * 🔒 fix: Run the Live Specs That Share the USER Role Profile One at a Time * 🪦 feat: Log Request Tombstones and Memory Watermarks, Closing Out Abandoned Requests as 499 (#197) The Bun server logged nothing per request and nothing about memory, so a crash or OOMKill left no trace of the request in flight or of the climb that preceded it. Each request now logs "[req] METHOD /path" on arrival, before the handler runs, and "[req] METHOD /path STATUS Nms" once it is answered, so an arrival without a completion names a request the process died holding. Paths are logged without query strings and cut at 200 characters, and at most 200 requests are logged per 10 seconds, with the number held back reported when the next window opens. ADMIN_PANEL_REQUEST_LOG=false turns the log off. The log and the HTTP metrics record one status: 499 when the client abandoned the request before it was answered, else the response's own. Bun neither reads nor cancels the body of an abandoned request, so such a request is closed out at once instead of waiting on its body, and a streamed body settles on the first of drain, error, or the request's abort signal, once. HEAD responses and the Bun.file static routes are logged at once and their bodies left untouched. Every other body passes through a stream Bun cannot size, so server-function responses now go out chunked instead of with a Content-Length. A 10-second check logs "[mem] rss=... heapUsed=... (crossed ...)" each time RSS climbs past one of ADMIN_PANEL_MEMORY_LOG_THRESHOLDS_MB (default 256,384,448 MiB); a threshold re-arms once RSS drops back below it. The wrapper and its helpers live in src/server/logging.ts, where tsc, eslint and vitest cover them; server.ts only wires them up. Both variables are documented in .env.example. Builds on the request tombstones and memory watermarks proposed in #102. * 🧩 fix: Import and Save Enum Values the Bundled Schema Doesn't List Yet (#198) A librechat.yaml written for a LibreChat newer than the panel's bundled librechat-data-provider failed to import with "Invalid enum value", for example `interface.retentionMode: ephemeral` (LibreChat dev has it, the bundled 0.8.524 does not). The import parse now tolerates an invalid_enum_value issue whose received value is text, keeps the YAML as written and reports each such value with its path and the values the panel lists (unknownEnumValues). Every other issue still fails the import: a number where an enum belongs, a wrong type, a union none of whose branches fits, and a mixed YAML where such errors come with unknown values. The wizard's review step names, in a non-blocking notice, each such value the import writes and its path: one in a row left checked, and not inside a key the import leaves out. The notice says the value is imported as written, that LibreChat v0.8.8 and later refuse a value they don't know when they save it, and that earlier releases store it without checking, so the admin should check the spelling. The save pre-flight (planConfigChanges) treats the same issues as non-blocking, for field and entry validation and for the whole-section pass, so the plan writes the value: an imported one, or a select or capability list that already holds one. Enum issues carry the value they refused (received), except inside a union's branch, which still blocks. LibreChat validates these writes from v0.8.8 (CONFIG_OVERRIDE_INVALID), and its refusal is still mapped onto the field, with issueParams where the server sends them. v0.8.5 to v0.8.7 serve the admin config API without validating values, and the panel can't tell them from v0.8.8: none of them serves the config meta route (v0.8.9-rc1 is the first). Blocking on a server without it would block v0.8.8, whose data-provider (0.8.527) is ahead of the bundled one, so on v0.8.5 to v0.8.7 the notice is the only check. The tests use made-up members (`someday`, `teleport`), so they keep testing the same case after a data-provider bump. Supersedes #112 by @dustinhealy, reusing its tolerance policy (which it took from #82), not its code. Closes #72 * ⏱️ fix: Stop the Event-Loop Delay Monitor From Waking the Idle Server 100 Times a Second (#199) collectDefaultMetrics() ran with prom-client's default eventLoopMonitoringPrecision of 10 ms. That is a monitorEventLoopDelay histogram sampling every 10 ms, so Bun's main thread woke about 100 times a second with no traffic. The panel now samples once a second, which keeps the nodejs_eventloop_lag_{min,max,mean,stddev,p50,p90,p99}_seconds metrics. ADMIN_PANEL_METRICS_EVENT_LOOP_PRECISION_MS overrides the interval and is documented in .env.example. A value that isn't a whole number from 1 to 2147483647 falls back to 1000: monitorEventLoopDelay throws for anything else (Bun's ceiling is 2147483647), and prom-client would rethrow it at startup. Idle main-thread wakeups of `bun server.ts` on a production build went from about 100 a second to 1 a second on Bun 1.4.0 and 1.4.3. Refs #97 * ⬆️ chore: Refresh Dependencies Within Their Majors and Clear the Fixable Audit Findings (#189) * ⬆️ chore: Refresh Dependencies Within Their Majors and Clear the Fixable Audit Findings Moves every direct dependency to the newest release inside its current major, and the override pins with them: vite 8.3.5, vitest 4.1.12 (every @vitest/* package follows), zod 4.6.7, eslint 10.13.0, react/react-dom 19.3.1 with @types/react and @types/react-dom 19.3.0, tailwindcss, @tailwindcss/vite and @tailwindcss/node 4.3.3, @radix-ui/react-dialog 1.3.1 (its dismissable-layer dedupe pin moves to 1.2.1, so one copy remains), @tanstack/react-devtools 0.10.13 (devtools 0.15.0, devtools-ui 0.7.1), input-otp 1.5.0, styled-components 6.6.0, lodash and lodash-es 4.18.1, mongoose 8.24.7, prettier 3.9.10, @typescript-eslint/* 8.72.0, eslint-plugin-import-x 4.17.1, eslint-plugin-tailwind-canonical-classes 1.6.0, @playwright/test 1.64.1, @axe-core/playwright 4.14.0, the @testing-library patches, @types/node 22.20.5 and @types/bun 1.4.4. 0.x packages stay inside their minor, except prettier-plugin-tailwindcss 0.7.4 -> 0.8.1, whose one breaking change is requiring Prettier 3.7 or newer. The override pins for postcss (vite 8.3 needs ^8.5.30), browserslist, baseline-browser-mapping, katex, nanoid, ws and yaml move to their newest patch. The undici pin is dropped: nothing in the tree depends on undici. TanStack router and start stay at 1.169.4's set, with seroval, srvx and solid-js. bun audit goes from 33 findings (1 critical, 17 high, 14 moderate, 1 low) to 1. launch-editor 2.14.2, shell-quote 1.12.0, follow-redirects 1.16.1, tmp 0.2.7, brace-expansion (1.1.21 and 5.0.12) and @humanfs/node 0.16.8 are resolved inside the ranges their parents declare, so none of them needs an override. The remaining finding is zod 3.25.76 under librechat-data-provider (GHSA-mrhx-c7xc-m3w5), which requires zod 3 and has no patched 3.x release. The tree was resolved with a one-day release-age floor, so i18next-browser-languagedetector stays at 8.2.2, baseline-browser-mapping at 2.11.32, follow-redirects at 1.16.1 and unplugin at 3.4.0. bun.lock keeps "lockfileVersion": 1, which Bun 1.2.0 and newer read; CI (latest Bun) and the Docker image (1.4.3) both install it frozen. Bun 1.4 labels a from-scratch resolve 2, which only Bun 1.4 and newer read. @librechat/data-schemas stays at 0.0.74: 0.0.75 through 0.0.77 import runtime values that librechat-data-provider 0.8.524, the newest on npm, does not export. input-otp 1.5 clears its selection re-sync timers on unmount, so AuthCard's tests drop the fake clock that kept them from firing into a torn-down jsdom; the teardown test now fails if they leak again. The canonical-classes rule rewrites has-[:disabled]: to has-disabled: (same CSS), and the Vite config uses import.meta.dirname in place of __dirname. TanStack Devtools 0.15 adds a resize handle with role="separator" to the dev server's page, so the 2FA e2e test looks for the OTP separator inside the OTP input. A separate commit applies Prettier 3.9's formatting to src, with no other changes. * 🎨 style: Apply the Prettier 3.9 Formatting to src * ⬆️ chore: Upgrade Click UI, i18next, TypeScript, Vitest and the Dev Tooling to Their New Majors (#194) * ⬆️ chore: Refresh Dependencies Within Their Majors and Clear the Fixable Audit Findings Moves every direct dependency to the newest release inside its current major, and the override pins with them: vite 8.3.5, vitest 4.1.12 (every @vitest/* package follows), zod 4.6.7, eslint 10.13.0, react/react-dom 19.3.1 with @types/react and @types/react-dom 19.3.0, tailwindcss, @tailwindcss/vite and @tailwindcss/node 4.3.3, @radix-ui/react-dialog 1.3.1 (its dismissable-layer dedupe pin moves to 1.2.1, so one copy remains), @tanstack/react-devtools 0.10.13 (devtools 0.15.0, devtools-ui 0.7.1), input-otp 1.5.0, styled-components 6.6.0, lodash and lodash-es 4.18.1, mongoose 8.24.7, prettier 3.9.10, @typescript-eslint/* 8.72.0, eslint-plugin-import-x 4.17.1, eslint-plugin-tailwind-canonical-classes 1.6.0, @playwright/test 1.64.1, @axe-core/playwright 4.14.0, the @testing-library patches, @types/node 22.20.5 and @types/bun 1.4.4. 0.x packages stay inside their minor, except prettier-plugin-tailwindcss 0.7.4 -> 0.8.1, whose one breaking change is requiring Prettier 3.7 or newer. The override pins for postcss (vite 8.3 needs ^8.5.30), browserslist, baseline-browser-mapping, katex, nanoid, ws and yaml move to their newest patch. The undici pin is dropped: nothing in the tree depends on undici. TanStack router and start stay at 1.169.4's set, with seroval, srvx and solid-js. bun audit goes from 33 findings (1 critical, 17 high, 14 moderate, 1 low) to 1. launch-editor 2.14.2, shell-quote 1.12.0, follow-redirects 1.16.1, tmp 0.2.7, brace-expansion (1.1.21 and 5.0.12) and @humanfs/node 0.16.8 are resolved inside the ranges their parents declare, so none of them needs an override. The remaining finding is zod 3.25.76 under librechat-data-provider (GHSA-mrhx-c7xc-m3w5), which requires zod 3 and has no patched 3.x release. The tree was resolved with a one-day release-age floor, so i18next-browser-languagedetector stays at 8.2.2, baseline-browser-mapping at 2.11.32, follow-redirects at 1.16.1 and unplugin at 3.4.0. bun.lock keeps "lockfileVersion": 1, which Bun 1.2.0 and newer read; CI (latest Bun) and the Docker image (1.4.3) both install it frozen. Bun 1.4 labels a from-scratch resolve 2, which only Bun 1.4 and newer read. @librechat/data-schemas stays at 0.0.74: 0.0.75 through 0.0.77 import runtime values that librechat-data-provider 0.8.524, the newest on npm, does not export. input-otp 1.5 clears its selection re-sync timers on unmount, so AuthCard's tests drop the fake clock that kept them from firing into a torn-down jsdom; the teardown test now fails if they leak again. The canonical-classes rule rewrites has-[:disabled]: to has-disabled: (same CSS), and the Vite config uses import.meta.dirname in place of __dirname. TanStack Devtools 0.15 adds a resize handle with role="separator" to the dev server's page, so the 2FA e2e test looks for the OTP separator inside the OTP input. A separate commit applies Prettier 3.9's formatting to src, with no other changes. * 🎨 style: Apply the Prettier 3.9 Formatting to src * ⬆️ chore: Upgrade i18next to 26 and react-i18next to 17 i18next 25.10.10 -> 26.5.10 and react-i18next 16.6.6 -> 17.1.8, resolved with `--minimum-release-age=86400` like the rest of the refresh, so i18next 26.5.11 and react-i18next 17.1.9 (both from the last day) stay out. react-i18next 17 needs i18next >= 26.2.0, so the two move together. i18next no longer depends on @babel/runtime, and react-i18next's html-parse-stringify moves to 4.0.1, which drops void-elements from the tree. i18next-browser-languagedetector 8.2.2 has no i18next peer and works unchanged. Nothing in the app needed to change. i18next 26 removes initImmediate, the legacy interpolation.format function, showSupportNotice and simplifyPluralSuffix, none of which src/locales/i18n.ts sets. The console support notice goes with it: every page load printed the Locize notice in the browser console before. react-i18next 17's one breaking change is the key generates for kept HTML nodes, and the panel does not use . src/locales/i18n.test.ts renders every key of translation.json through the app's i18n instance with a value for each {{placeholder}} and expects its own text, so no key can fall back to its id or come out escaped. It also checks that each _zero/_one/_other base picks the right form for 0, 1, 2 and 21, and that useLocalize interpolates and pluralizes. The test passes on i18next 25 / react-i18next 16 as well, and fails with escapeValue: true or with empty resources. * ⬆️ chore: Upgrade Click UI to 0.15.0 and lucide-react to 1.61.0 Click UI 0.9.1 -> 0.15.0, read through every release in between (0.10.0, 0.11.0, 0.12.0, 0.13.0, 0.14.0, 0.15.0). None of the API breaks reach this app, so no component code changes: - 0.10 drops styled-components and wraps all Click UI CSS in one `clickui` cascade layer. The app never imported styled-components itself (no ThemeProvider bridge, linkStyles or useTheme), so it leaves package.json and the Vite optimizeDeps list, and @emotion/*, stylis, camelize and css-to-react-native leave the tree. styles.css loads first, so the layer order is properties, theme, base, components, utilities, clickui in the build and in `vite dev`: Click UI still beats Tailwind utilities and preflight as its unlayered styles did, and the unlayered rules in styles.css (focus rings, switch and dark-accent tokens) still beat Click UI. Every --click-* token the app overrides still exists in 0.15. - 0.11 bumps Radix to the 1.6.7 set. The react-dialog 1.3.1 and dismissable-layer 1.2.1 override pins still leave one dismissable-layer; react-popper now has one copy instead of two, and the focus-scope/focus-guards copies nested under menu and popover are the same split the tree had before. IconButton, Toast role and Table header changes touch nothing the app renders or tests. - 0.13's DropdownItemProps union, FileTab/Dropdown.Sub prop types and token semicolons, and 0.15's Tooltip.Trigger asChild forwarding, are unused here. - 0.13 stops IconWrapper stacking a menu item's icon above its label at 768px and below, so the styles.css rule that forced those rows back to one line goes. The dialog close-button naming and the Dropdown.Trigger aria-expanded workarounds stay: 0.15 still renders the close button unnamed and still puts aria-expanded on the trigger's wrapper. Visible change: 0.15 keeps Dropdown menus 8px from the viewport edge instead of 100px, so the user menu opens flush with the left edge (over the icon rail) and, on a phone, row action menus are wider and wrap less. Checked against the 0.9.1 build on a live stack, light and dark at 1440 and 390 wide (login and its error, configuration tabs, scope selector, endpoint drawer and its select, row action menu, preview drawer, provenance hover card, review dialog, discard toast, Import YAML, access roles/groups, role dialog tabs, delete confirmation, create role, grants and capability dialog, audit log, date picker, command menu, user menu): 156 of 174 screenshot pairs are pixel-identical; the rest are those menus and audit-log loading timing. Dropping the menu-row rule changed no pixel at either width. The production client bundle grows by 62 kB gzipped, mostly new logos and flags in the icons chunk. lucide-react ^0.545.0 -> ^1.61.0. Nothing in src imports it; v1 drops brand icons and sets aria-hidden by default, neither of which applies. Resolved with `bun install --minimum-release-age=86400`; bun.lock keeps lockfileVersion 1. The .claude click-ui skill no longer tells agents to extend components with styled-components, and its examples only use Tailwind for properties the clickui layer leaves unset, since a utility can't override one Click UI sets. The ESLint plugin's comment on the disabled require-css-import rule now gives the CSS Modules reason. * ⬆️ chore: Upgrade TypeScript to 6.0.3 and Ready the Project for the TypeScript 7 Compiler TypeScript 7.0.2 is not adopted: typescript-eslint 8.72 peers on typescript >=4.8.4 <6.1.0 and its parser throws "typescript-eslint does not support TS 7.0" at load, because 7.0 ships no JS compiler API. 6.0.3 is inside that range and is the step toward 7: - Drop baseUrl from tsconfig. 6.0 rejects it as deprecated (TS5101) and 7.0 removes it (TS5102); paths already resolve from the tsconfig folder. - Annotate the CSRF middleware in start.ts. Under 7.0's stable type ordering (and 6.0 with --stableTypeOrdering) its inferred type cycles through the generated Register config (TS7022/TS7024). The built client and server bundles are byte-identical to the 5.9 build apart from the start manifest's absolute paths. * ⬆️ chore: Upgrade Vitest to 5, jsdom to 30 and jest-dom to 7 jsdom 30 computes box-sizing ("content-box"; 27 returned an empty string), so react-textarea-autosize now mounts its hidden measuring textarea in document.body and keeps it there between tests. A ListField test that read the first textarea in the document got that one (value "x") instead of the stop sequence; it and the test beside it now find the item's textarea by its accessible name. Vitest 5 needs Node 22.12+, jsdom 30 Node 22.22.2+ and jest-dom 7 Node 22+; the ubuntu-latest CI image ships Node 22.23.3. * ⬆️ chore: Upgrade @vitejs/plugin-react to 6 Version 6 drops its Babel pipeline and leaves the React Refresh transform to Vite 8's Oxc. The panel passes the plugin no Babel options, and the production client bundle is byte-identical to the one 5.2 builds. * ⬆️ chore: Upgrade lint-staged to 17 Version 17 needs Node 22.22.1+ and Git 2.32+, and makes yaml an optional dependency. .lintstagedrc.json stays JSON, so nothing else changes. * ⬆️ chore: Upgrade @tanstack/devtools-vite to 0.8 and Keep Its Console Piping Off devtools-vite 0.8 pipes server console output into the browser and browser console output back to the dev server by default. Vite 8 forwards browser warnings and errors to the terminal whenever it runs under a coding agent, so the two features feed each other: a single server warning on the login page re-logs itself with a growing [Server] prefix until the tab closes. 0.3.12 had no console piping, so turning it off keeps the dev server as it was. 0.8 also moves its event bus to an OS-assigned port when the default one is taken, where 0.3.12 crashed `vite dev` with EADDRINUSE on port 42069 whenever a second dev server was already running. Production builds still strip the devtools from __root.tsx, and the client bundle is byte-identical to 0.3.12's. * ⬆️ chore: Move dotenv to 18 and @types/node to the Node 26 API Bun 1.4 Reports - dotenv ^17.4.2 -> ^18.0.9. Nothing imports it (Bun and Vite load .env themselves), so the bump changes no runtime path. - @types/node ^22.20.5 -> ^26.6.5. The image runs on oven/bun:1.4.3, whose process.version is v26.3.0, and bun-types takes @types/node "*"; the types now describe the Node API the production runtime reports. Its undici-types moves to 8.9.0 and stays a single copy. Left on their current majors: - js-yaml 4: librechat-data-provider keeps js-yaml 4 in the client chunk, so 5 would ship both parsers (+17 KB gzip), would need the global js-yaml 4.3.2 security override dropped, and needs a compat layer to read and write YAML as LibreChat's own js-yaml 4 does. - mongoose 8: @librechat/data-schemas (0.0.74 and the latest 0.0.77) peers on mongoose ^8.24.1, as LibreChat itself does; the panel only lists it to pin the patched 8.24.x and never loads it at runtime. * 🎨 style: Drop the Select Ring Color Override Click UI 0.15 No Longer Needs and Reword the Sign-In Card's Layer Note * 🧪 fix: Wait Out the Langfuse Load-Time Verification in Its Tests and Bring the Live Model Specs Journey Up to Date (#204) LangfuseRenderer verifies a saved connection once its status renders, and while that request is in flight every control is disabled and the primary button reads "Checking" instead of "Save & enable". "preserves the stored secret when only the public key is edited" and "enables a disabled connection when credential edits are saved with Save & enable" clicked Edit as soon as the stored connection showed, before that verification had run or settled, so under load the click landed inside the in-flight window and the Save & enable lookup failed. renderVerified() now renders a saved connection and waits for the Verified status, which renders in the same pass that releases the controls; every test that acts on a saved connection uses it, with its assertions unchanged. The default verification mock answers on a later task, as a request does, so the window is always there and a test that acts inside it fails on every run instead of rarely. A new test holds the verification open and checks that Edit and Disable stay disabled until it settles. e2e/specs.spec.ts expected a new spec to open on Endpoint. That held only while a new spec showed its Model group first; the editor then went back to one group order for new and saved specs (Basics, then Model), so a new card focuses its Label, and the journey was never updated. The same journey also expected the card summary to read "openAI · gpt-4o-mini", but spec rows name the endpoint ("OpenAI") since the change that made them do so. addSpec now expects Label focused and fills Label, Endpoint and Model in that order, and the summary assertion expects "OpenAI · gpt-4o-mini". A unit test pins the new spec's group order and its Label focus, so CI catches the next change. * ♿ fix: Pass Every Axe Scan on the Grants and Access Pages (#203) On main, five axe scans of the mock e2e suite failed: the Access page's Groups and Roles tabs, and the Grants page's management tab, edit dialog and audit log tab. Each is fixed in the panel; no rule, tag or element is excluded from a scan. Color contrast. The success badge (Active on the grants table, Granted in the audit log) used click-ui 0.15's #008a0b on its #ebffec tint: 4.32:1 at 10px, and 4.02:1 on a hovered row, short of AA's 4.5:1. The unlayered `:root[data-cui-theme]` block in src/styles.css now sets `--click-badge-opaque-color-text-success` to the panel's own `--settings-success-fg`: #146c2e in light (6.23:1, 5.80:1 hovered) and #86efac in dark (7.04:1, 6.30:1 hovered; click-ui's dark #ccffd0 already passed, so dark only follows the panel's green). Nested interactive controls. - Each category header in the Edit capabilities dialog was a `div role="button"` holding its "Select all" switch. A shared SelectAllHeader now renders a disclosure ` + )} + 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__/CreateGroupDialog.test.tsx b/src/components/access/__tests__/CreateGroupDialog.test.tsx new file mode 100644 index 00000000..030ae874 --- /dev/null +++ b/src/components/access/__tests__/CreateGroupDialog.test.tsx @@ -0,0 +1,90 @@ +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 { AdminUserSearchResult } from '@librechat/data-schemas'; +import type * as t from '@/types'; +import { DIALOG_CLOSERS, expectFocusBack, openFrom, renderWithOpener } from '@/test/focus'; +import { addGroupMemberFn, createGroupFn } from '@/server'; +import { CreateGroupDialog } from '../CreateGroupDialog'; + +const USERS: AdminUserSearchResult[] = [ + { id: 'u1', name: 'Jane', email: 'jane@example.com' }, + { id: 'u2', name: 'Raj', email: 'raj@example.com' }, +]; + +vi.mock('@/hooks', async () => { + const { useRefreshScopes } = + await vi.importActual('@/hooks/config/scope'); + const { useReturnFocus } = + await vi.importActual('@/hooks/dialogs'); + return { + useLocalize: () => (key: string, options?: Record) => + options ? `${key}(${Object.values(options).join(',')})` : key, + useRefreshScopes, + useReturnFocus, + }; +}); + +vi.mock('@/components/shared', async (importOriginal) => ({ + ...(await importOriginal()), + UserSearchInline: ({ onAdd }: t.UserSearchInlineProps) => ( + + ), +})); + +vi.mock('@/server', () => ({ + availableScopesOptions: { queryKey: ['availableScopes'], queryFn: async () => [] }, + createGroupFn: vi.fn(), + addGroupMemberFn: vi.fn(), +})); + +describe('CreateGroupDialog save', () => { + it('refreshes the server-resolved reads when a later member addition fails', async () => { + vi.mocked(createGroupFn).mockResolvedValue({ + group: { + id: 'g1', + name: 'Platform', + description: '', + memberCount: 0, + topMembers: [], + isActive: true, + }, + }); + vi.mocked(addGroupMemberFn) + .mockResolvedValueOnce({ success: true }) + .mockRejectedValueOnce(new Error('boom')); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + queryClient.setQueryData(['configPreview', 'user', 'u1'], { ok: true }); + queryClient.setQueryData(['groups', 'all'], []); + fireEvent.change(await screen.findByLabelText('com_access_col_name'), { + target: { value: 'Platform' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'add-users' })); + fireEvent.click(screen.getByRole('button', { name: 'com_access_create_group' })); + await waitFor(() => expect(addGroupMemberFn).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(queryClient.getQueryState(['configPreview', 'user', 'u1'])?.isInvalidated).toBe(true), + ); + expect(queryClient.getQueryState(['groups', 'all'])?.isInvalidated).toBe(true); + }); +}); + +describe('CreateGroupDialog focus', () => { + it.each(DIALOG_CLOSERS)( + 'gives focus back to the button that opened it on %s', + async (_, close) => { + const opener = renderWithOpener((open, onClose) => ( + + )); + close(await openFrom(opener)); + await expectFocusBack(opener); + }, + ); +}); diff --git a/src/components/access/__tests__/CreateRoleDialog.test.tsx b/src/components/access/__tests__/CreateRoleDialog.test.tsx new file mode 100644 index 00000000..08f58fb7 --- /dev/null +++ b/src/components/access/__tests__/CreateRoleDialog.test.tsx @@ -0,0 +1,95 @@ +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 { AdminUserSearchResult } from '@librechat/data-schemas'; +import type * as t from '@/types'; +import { DIALOG_CLOSERS, expectFocusBack, openFrom, renderWithOpener } from '@/test/focus'; +import { addRoleMemberFn, createRoleFn, updateRolePermissionsFn } from '@/server'; +import { CreateRoleDialog } from '../CreateRoleDialog'; + +const USERS: AdminUserSearchResult[] = [ + { id: 'u1', name: 'Jane', email: 'jane@example.com' }, + { id: 'u2', name: 'Raj', email: 'raj@example.com' }, +]; + +const role = { + id: 'r1', + name: 'WRITERS', + description: '', + isSystemRole: false, + isActive: true, + userCount: 0, + permissions: {}, +} as t.Role; + +vi.mock('@/hooks', async () => { + const { useRefreshScopes } = + await vi.importActual('@/hooks/config/scope'); + const { useReturnFocus } = + await vi.importActual('@/hooks/dialogs'); + return { + useLocalize: () => (key: string, options?: Record) => + options ? `${key}(${Object.values(options).join(',')})` : key, + useRefreshScopes, + useReturnFocus, + }; +}); + +vi.mock('@/components/shared', async (importOriginal) => ({ + ...(await importOriginal()), + UserSearchInline: ({ onAdd }: t.UserSearchInlineProps) => ( + + ), +})); + +vi.mock('../RolePermissionsPanel', () => ({ RolePermissionsPanel: () => null })); + +vi.mock('@/server', () => ({ + availableScopesOptions: { queryKey: ['availableScopes'], queryFn: async () => [] }, + createRoleFn: vi.fn(), + addRoleMemberFn: vi.fn(), + updateRolePermissionsFn: vi.fn(), +})); + +describe('CreateRoleDialog save', () => { + it('refreshes the server-resolved reads when a later member addition fails', async () => { + vi.mocked(createRoleFn).mockResolvedValue({ role }); + vi.mocked(updateRolePermissionsFn).mockResolvedValue({ role }); + vi.mocked(addRoleMemberFn) + .mockResolvedValueOnce({ success: true }) + .mockRejectedValueOnce(new Error('boom')); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + queryClient.setQueryData(['configPreview', 'user', 'u1'], { ok: true }); + queryClient.setQueryData(['roles', 'all'], []); + fireEvent.change(await screen.findByLabelText('com_access_col_name'), { + target: { value: 'WRITERS' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'add-users' })); + fireEvent.click(screen.getByRole('button', { name: 'com_access_create_role' })); + await waitFor(() => expect(addRoleMemberFn).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(queryClient.getQueryState(['configPreview', 'user', 'u1'])?.isInvalidated).toBe(true), + ); + expect(queryClient.getQueryState(['roles', 'all'])?.isInvalidated).toBe(true); + }); +}); + +describe('CreateRoleDialog focus', () => { + it.each(DIALOG_CLOSERS)( + 'gives focus back to the button that opened it on %s', + async (_, close) => { + const opener = renderWithOpener((open, onClose) => ( + + )); + close(await openFrom(opener)); + await expectFocusBack(opener); + }, + ); +}); diff --git a/src/components/access/__tests__/EditGroupDialog.test.tsx b/src/components/access/__tests__/EditGroupDialog.test.tsx new file mode 100644 index 00000000..6ce60c4d --- /dev/null +++ b/src/components/access/__tests__/EditGroupDialog.test.tsx @@ -0,0 +1,88 @@ +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 { AdminGroup } from '@librechat/data-schemas'; +import { DIALOG_CLOSERS, expectFocusBack, openFrom, renderWithOpener } from '@/test/focus'; +import { removeGroupMemberFn, updateGroupFn } from '@/server'; +import { EditGroupDialog } from '../EditGroupDialog'; + +vi.mock('@/hooks', async () => { + const { useRefreshScopes } = + await vi.importActual('@/hooks/config/scope'); + const { useReturnFocus } = + await vi.importActual('@/hooks/dialogs'); + return { + useLocalize: () => (key: string, options?: Record) => + options ? `${key}(${Object.values(options).join(',')})` : key, + useRefreshScopes, + useReturnFocus, + }; +}); + +vi.mock('@/server', () => ({ + MEMBERS_PAGE_SIZE: 20, + groupMembersQueryOptions: (id: string, page: number) => ({ + queryKey: ['groupMembers', id, page], + queryFn: async () => ({ + members: [{ userId: 'u1', name: 'Jane', email: 'jane@example.com' }], + total: 1, + }), + }), + availableScopesOptions: { queryKey: ['availableScopes'], queryFn: async () => [] }, + updateGroupFn: vi.fn(), + addGroupMemberFn: vi.fn(), + removeGroupMemberFn: vi.fn(), +})); + +const group: AdminGroup = { + id: 'g1', + name: 'Engineering', + description: '', + memberCount: 1, + topMembers: [{ name: 'Jane' }], + isActive: true, +}; + +describe('EditGroupDialog save', () => { + it('refreshes the server-resolved reads when part of the save fails after other parts landed', async () => { + vi.mocked(updateGroupFn).mockResolvedValue({ group: { ...group, name: 'Platform' } }); + vi.mocked(removeGroupMemberFn).mockRejectedValue(new Error('boom')); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + queryClient.setQueryData(['configPreview', 'user', 'u1'], { ok: true }); + queryClient.setQueryData(['envStatus'], {}); + fireEvent.change(await screen.findByLabelText('com_access_col_name'), { + target: { value: 'Platform' }, + }); + fireEvent.mouseDown(screen.getByRole('tab', { name: 'com_access_tab_members' })); + fireEvent.click(await screen.findByLabelText('com_access_remove_member Jane')); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_save' })); + await waitFor(() => expect(removeGroupMemberFn).toHaveBeenCalled()); + await waitFor(() => + expect(queryClient.getQueryState(['configPreview', 'user', 'u1'])?.isInvalidated).toBe(true), + ); + expect(queryClient.getQueryState(['envStatus'])?.isInvalidated).toBe(true); + }); +}); + +describe('EditGroupDialog focus', () => { + it.each(DIALOG_CLOSERS)( + 'gives focus back to the group row that opened it on %s', + async (_, close) => { + const opener = renderWithOpener((open, onClose) => ( + + )); + close(await openFrom(opener)); + await expectFocusBack(opener); + }, + ); +}); diff --git a/src/components/access/__tests__/EditRoleDialog.test.tsx b/src/components/access/__tests__/EditRoleDialog.test.tsx new file mode 100644 index 00000000..f2e1436f --- /dev/null +++ b/src/components/access/__tests__/EditRoleDialog.test.tsx @@ -0,0 +1,170 @@ +import { PrincipalType } from 'librechat-data-provider'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type * as t from '@/types'; +import { DIALOG_CLOSERS, expectFocusBack, openFrom, renderWithOpener } from '@/test/focus'; +import { removeRoleMemberFn, updateRoleFn } from '@/server'; +import { BUNDLED_CONFIG_FEATURES } from '@/constants'; +import { EditRoleDialog } from '../EditRoleDialog'; + +const state = vi.hoisted(() => ({ + features: {} as Record, + scopes: [] as Array>, + members: [] as Array<{ userId: string; name: string; email: string }>, +})); + +vi.mock('@/hooks', async () => { + const { useRefreshScopes } = + await vi.importActual('@/hooks/config/scope'); + const { useReturnFocus } = + await vi.importActual('@/hooks/dialogs'); + return { + useLocalize: () => (key: string, options?: Record) => + options ? `${key}(${Object.values(options).join(',')})` : key, + useConfigFeatures: () => state.features, + useRefreshScopes, + useReturnFocus, + }; +}); + +vi.mock('@/server', () => ({ + MEMBERS_PAGE_SIZE: 20, + roleQueryOptions: (name: string) => ({ + queryKey: ['role', name], + queryFn: async () => ({ ...role, permissions: {} }), + }), + roleMembersQueryOptions: (id: string, page: number) => ({ + queryKey: ['roleMembers', id, page], + queryFn: async () => ({ members: state.members, total: state.members.length }), + }), + availableScopesOptions: { + queryKey: ['availableScopes'], + queryFn: async () => state.scopes, + }, + updateRoleFn: vi.fn(), + addRoleMemberFn: vi.fn(), + removeRoleMemberFn: vi.fn(), + updateRolePermissionsFn: vi.fn(), +})); + +vi.mock('../RolePermissionsPanel', () => ({ RolePermissionsPanel: () => null })); + +const role: t.Role = { + id: 'r1', + name: 'EDITORS', + description: '', + isSystemRole: false, + isActive: true, + userCount: 0, + permissions: {}, +} as t.Role; + +function renderDialog(): QueryClient { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + return queryClient; +} + +async function rename(name: string) { + const input = await screen.findByLabelText('com_access_col_name'); + fireEvent.change(input, { target: { value: name } }); +} + +beforeEach(() => { + state.features = { ...BUNDLED_CONFIG_FEATURES }; + state.scopes = [{ principalType: PrincipalType.ROLE, principalId: 'EDITORS' }]; + state.members = []; +}); + +describe('EditRoleDialog rename note', () => { + it('warns that the profile stays under the old name on a server that does not move it', async () => { + renderDialog(); + await rename('WRITERS'); + expect( + await screen.findByText('com_access_rename_profile_warning(EDITORS)'), + ).toBeInTheDocument(); + }); + + it('says the profile moves with the role when the server moves it', async () => { + state.features = { ...BUNDLED_CONFIG_FEATURES, roleRenameMovesProfile: true }; + renderDialog(); + await rename('WRITERS'); + expect(await screen.findByText('com_access_rename_profile_moves')).toBeInTheDocument(); + expect(screen.queryByText(/com_access_rename_profile_warning/)).not.toBeInTheDocument(); + }); + + it('says nothing once the name is back to the current one', async () => { + renderDialog(); + await rename('WRITERS'); + expect(await screen.findByText(/com_access_rename_profile_warning/)).toBeInTheDocument(); + await rename('EDITORS'); + expect(screen.queryByText(/com_access_rename_profile/)).not.toBeInTheDocument(); + }); + + it('says nothing for a role without a profile', async () => { + state.scopes = [{ principalType: PrincipalType.GROUP, principalId: 'EDITORS' }]; + renderDialog(); + await rename('WRITERS'); + await act(async () => {}); + expect(screen.queryByText(/com_access_rename_profile/)).not.toBeInTheDocument(); + }); +}); + +describe('EditRoleDialog save', () => { + it("refreshes the profiles and every server-resolved read, since a user's role resolves their config", async () => { + vi.mocked(updateRoleFn).mockResolvedValue({ role: { ...role, name: 'WRITERS' } }); + const queryClient = renderDialog(); + queryClient.setQueryData(['configPreview', 'user', 'u1'], { ok: true }); + queryClient.setQueryData(['configCatalog', 'role', 'EDITORS'], {}); + queryClient.setQueryData(['envStatus'], {}); + await rename('WRITERS'); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_save' })); + await waitFor(() => + expect(queryClient.getQueryState(['configPreview', 'user', 'u1'])?.isInvalidated).toBe(true), + ); + expect(queryClient.getQueryState(['configCatalog', 'role', 'EDITORS'])?.isInvalidated).toBe( + true, + ); + expect(queryClient.getQueryState(['envStatus'])?.isInvalidated).toBe(true); + }); + + it('refreshes the server-resolved reads when part of the save fails after other parts landed', async () => { + state.members = [{ userId: 'u1', name: 'Jane', email: 'jane@example.com' }]; + vi.mocked(updateRoleFn).mockResolvedValue({ role }); + vi.mocked(removeRoleMemberFn).mockRejectedValue(new Error('boom')); + const queryClient = renderDialog(); + queryClient.setQueryData(['configPreview', 'user', 'u1'], { ok: true }); + queryClient.setQueryData(['envStatus'], {}); + fireEvent.mouseDown(await screen.findByRole('tab', { name: 'com_access_tab_members' })); + fireEvent.click(await screen.findByLabelText('com_access_remove_member Jane')); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_save' })); + await waitFor(() => expect(removeRoleMemberFn).toHaveBeenCalled()); + await waitFor(() => + expect(queryClient.getQueryState(['configPreview', 'user', 'u1'])?.isInvalidated).toBe(true), + ); + expect(queryClient.getQueryState(['envStatus'])?.isInvalidated).toBe(true); + }); +}); + +describe('EditRoleDialog focus', () => { + it.each(DIALOG_CLOSERS)( + 'gives focus back to the role row that opened it on %s', + async (_, close) => { + const opener = renderWithOpener((open, onClose) => ( + + )); + close(await openFrom(opener)); + await expectFocusBack(opener); + }, + ); +}); diff --git a/src/components/access/__tests__/GroupsTab.test.tsx b/src/components/access/__tests__/GroupsTab.test.tsx new file mode 100644 index 00000000..623da53c --- /dev/null +++ b/src/components/access/__tests__/GroupsTab.test.tsx @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { apiFetch } from '@/server/utils/api'; +import { GroupsTab } from '../GroupsTab'; + +vi.mock('@tanstack/react-start', () => ({ + createServerFn: () => ({ + handler: (fn: (...args: never[]) => unknown) => fn, + validator: () => ({ handler: (fn: (...args: never[]) => unknown) => fn }), + }), +})); + +vi.mock('@/server/utils/api', () => ({ + apiFetch: vi.fn( + async () => + new Response( + JSON.stringify({ + groups: [{ _id: 'g1', name: 'Engineering', memberIds: [], source: 'local' }], + total: 1, + }), + { status: 200 }, + ), + ), + extractApiError: async () => { + throw new Error('unexpected'); + }, +})); + +vi.mock('@/server', () => vi.importActual('@/server/groups')); + +vi.mock('@/hooks', async () => ({ + useLocalize: () => (key: string, options?: Record) => + options ? `${key}(${Object.values(options).join(',')})` : key, + useCapabilities: () => ({ hasCapability: () => true }), + useRefreshScopes: () => vi.fn(), + useClearButtonLabel: 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. */ +function sentSearches(): string[] { + return vi + .mocked(apiFetch) + .mock.calls.map(([path]) => new URL(path, 'http://admin.test').searchParams.get('search')) + .filter((search): search is string => search != null); +} + +describe('GroupsTab search', () => { + it('clamps a pasted search to the 200 characters the backend accepts, in the field, query and request', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + const field = await screen.findByPlaceholderText('com_access_search_groups'); + fireEvent.change(field, { target: { value: 'x'.repeat(250) } }); + expect(field).toHaveValue('x'.repeat(200)); + await waitFor(() => expect(sentSearches()).toEqual(['x'.repeat(200)])); + expect(queryClient.getQueryData(['groups', 1, 'x'.repeat(200)])).toBeDefined(); + const clamped = queryClient.getQueryCache().find({ queryKey: ['groups', 1, 'x'.repeat(250)] }); + 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__/RolePermissionsPanel.test.tsx b/src/components/access/__tests__/RolePermissionsPanel.test.tsx new file mode 100644 index 00000000..d2893387 --- /dev/null +++ b/src/components/access/__tests__/RolePermissionsPanel.test.tsx @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import { PermissionTypes } from 'librechat-data-provider'; +import { RolePermissionsPanel } from '../RolePermissionsPanel'; +import { defaultPermissions } from '@/constants'; +import { nestedControls } from '@/test/a11y'; + +vi.mock('@/hooks', () => ({ useLocalize: () => (key: string) => key })); + +const PROMPTS = `com_perm_type_${PermissionTypes.PROMPTS}`; + +describe('RolePermissionsPanel permission headers', () => { + it('puts each Select all switch beside its permission toggle, not inside it', () => { + const { container } = render( + , + ); + + expect(nestedControls(container)).toEqual([]); + expect(screen.getByRole('switch', { name: `${PROMPTS}: com_ui_select_all` })).toBeVisible(); + }); + + it('opens a permission type from its toggle and turns it all on from Select all', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + const toggle = screen.getByRole('button', { name: new RegExp(`^${PROMPTS}`) }); + + await user.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + + await user.click(screen.getByRole('switch', { name: `${PROMPTS}: com_ui_select_all` })); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ [PermissionTypes.PROMPTS]: expect.any(Object) }), + ); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); +}); 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/configuration/ConfigLoadError.tsx b/src/components/configuration/ConfigLoadError.tsx new file mode 100644 index 00000000..fe0ea5d2 --- /dev/null +++ b/src/components/configuration/ConfigLoadError.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef } from 'react'; +import { Button, Icon } from '@clickhouse/click-ui'; +import type * as t from '@/types'; +import { activeConfigTab, restoreFocus, restoreFocusWhenShown } from '@/utils'; +import { useLocalize } from '@/hooks'; + +function errorMessage(error: unknown): string | null { + if (error instanceof Error) return error.message || null; + if (typeof error === 'string') return error || null; + return null; +} + +/** + * The Retry of a load error card shown again after a retry failed: the route + * and the page both mount a new card in place of the one that retried. + */ +function retryShownAgain(): HTMLElement | null { + return document.querySelector('[data-config-load-error] button:not([disabled])'); +} + +/** The open tab once the page has loaded: none while a skeleton (`aria-busy`) stands in for it. */ +function loadedConfigTab(): HTMLElement | null { + return document.querySelector('[aria-busy="true"]') ? null : activeConfigTab(); +} + +/** + * Danger card with Retry, shown in place of the configuration form when its + * data fails to load. A retry takes the card, and the focused Retry with it, + * away while it loads: focus goes to the open tab once the page has loaded, + * or to the Retry of the card shown again if it failed again, unless the + * user has gone to another page by then. + */ +export function ConfigLoadError({ + error, + onRetry, + retrying, + titleKey = 'com_config_shell_load_error_title', + descriptionKey = 'com_config_shell_load_error_desc', +}: t.ConfigLoadErrorProps) { + const localize = useLocalize(); + const details = errorMessage(error); + const alertRef = useRef(null); + const retriedFrom = useRef(null); + const wasRetrying = useRef(retrying); + + useEffect(() => { + const was = wasRetrying.current; + wasRetrying.current = retrying; + if (!was || retrying) return; + retriedFrom.current = null; + restoreFocus([alertRef.current?.querySelector('button')]); + }, [retrying]); + + useEffect( + () => () => { + const path = retriedFrom.current; + if (path) restoreFocusWhenShown([retryShownAgain, loadedConfigTab], null, path); + }, + [], + ); + + return ( +
+
+
+ +
+

+ {localize(titleKey)} +

+

{localize(descriptionKey)}

+ {details && ( +
+ + {localize('com_config_shell_load_error_details')} + + + {details} + +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 51063b8c..4090d4b7 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -1,949 +1,523 @@ -import { createPortal } from 'react-dom'; -import { Icon } from '@clickhouse/click-ui'; -import { PrincipalType } from 'librechat-data-provider'; -import { getRouteApi, useBlocker, useNavigate } from '@tanstack/react-router'; -import { useState, useMemo, useRef, useCallback, useEffect, startTransition } from 'react'; -import { queryOptions, useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { getRouteApi, useNavigate } from '@tanstack/react-router'; +import { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import type * as t from '@/types'; import { - removeFieldProfileValueFn, - tombstoneFieldProfileValueFn, - bulkSaveProfileValuesFn, - getBatchFieldProfilesFn, - availableScopesOptions, - resetBaseConfigFieldFn, - getResolvedConfigFn, - importBaseConfigFn, - resetBaseConfigFn, - baseConfigOptions, - saveBaseConfigFn, - getLangfuseConnectionFn, - LANGFUSE_CONNECTION_QUERY_KEY, -} from '@/server'; + useLocalize, + useTabBadges, + useConfigSave, + useTabSections, + useConfigEdits, + useConfigTabs, + useScopeIndex, + useConfigLayers, + useConfigImport, + useChangeReview, + useBaseOverrides, + useRevealField, + useConfigBaseline, + useNavigationGuard, + useActiveSection, + useScopeSelection, + useScopeGatedSections, + useResetBaseConfig, + useConfigSearchIndex, + usePendingConfigValues, +} from '@/hooks'; import { - flattenObject, - unflattenObject, - deepSerializeKVPairs, - normalizeImportConfig, - hasConfigCapability, - getTabsWithPermission, - mapSecretPreviewPaths, - secretPathForPreviewPath, - stripSecretPreviewValues, - notifySuccess, notifyError, + isRowVisible, + moveEntryPath, + notifyWarning, + findEntryIndex, + getValueAtPath, + parseScopeParam, + createPathIndex, + formatScopeParam, + findScopeByParam, + toScopeCandidate, + getKeyedCollection, + isConfigValueEqual, } from '@/utils'; -import { useLocalize, useHighlightRef, useActiveSection, useCapabilities } from '@/hooks'; -import { CONFIG_TABS, OTHER_TAB, SECTION_META, HIDDEN_SECTIONS } from './configMeta'; -import { - applyConfigEdit, - buildSavePayload, - mergeIndexedArrayEdits, - partitionScopeResetPaths, - withLangfuseConfiguredPath, -} from './utils'; -import { validateMcpCrossField } from './sections/McpServersRenderer'; -import { ScopeSelector, ScopeTriggerButton } from './ScopeSelector'; -import { StickyActionBar } from '@/components/shared'; +import { CollectionEditsContext } from '@/contexts/CollectionEditsContext'; +import { ConfigErrorsProvider } from '@/contexts/ConfigErrorsContext'; +import { buildTocEntries, findTocEntry, toJumpTargets } from './toc'; +import { RevealTargetContext } from '@/contexts/RevealTargetContext'; +import { GlobalRevealContext } from '@/contexts/GlobalRevealContext'; import { ConfigTableOfContents } from './ConfigTableOfContents'; import { ResetBaseConfigDialog } from './ResetBaseConfigDialog'; -import { ConfirmSaveDialog } from './ConfirmSaveDialog'; +import { NavigationGuardDialog } from './NavigationGuardDialog'; +import { ScopeActionsProvider } from '@/contexts/ScopeContext'; +import { RestatesContext } from '@/contexts/RestatesContext'; +import { RenamesContext } from '@/contexts/RenamesContext'; +import { ExpandProvider } from '@/contexts/ExpandContext'; +import { ConfigContentSkeleton } from './ConfigSkeleton'; +import { isPathUnder, tabForPath } from './configMeta'; import { ConfigTabContent } from './ConfigTabContent'; import { ImportYamlDialog } from './ImportYamlDialog'; +import { ChangeReview, ChangesBar } from './review'; +import { ConfigLoadError } from './ConfigLoadError'; import { ContentToolbar } from './ContentToolbar'; +import { availableScopesOptions } from '@/server'; import { SystemCapabilities } from '@/constants'; +import { ScopeSelector } from './ScopeSelector'; +import { ScopeNotFound } from './ScopeNotFound'; +import { HeaderActions } from './HeaderActions'; import { ConfigTabBar } from './ConfigTabBar'; -import { InfoBanner } from './InfoBanner'; +import { searchConfigIndex } from './search'; +import { revealConfigPath } from './reveal'; +import { PreviewDrawer } from './preview'; +import { ScopeBar } from './ScopeBar'; const routeApi = getRouteApi('/_app/configuration/'); -const LAST_SCOPE_KEY = 'config:lastScope'; - -function collectFieldPaths(fields: t.SchemaField[], prefix = ''): string[] { - const paths: string[] = []; - for (const field of fields) { - const path = prefix ? `${prefix}.${field.key}` : field.key; - if (field.children && field.children.length > 0) { - paths.push(...collectFieldPaths(field.children, path)); - } else { - paths.push(path); - } - } - return paths; -} -const profileMapOptions = (fieldPaths: string[]) => - queryOptions({ - queryKey: ['profileMap', fieldPaths], - queryFn: () => - getBatchFieldProfilesFn({ data: { paths: fieldPaths } }).then( - (r: { profileMap: Record }) => r.profileMap, - ), - enabled: fieldPaths.length > 0, - staleTime: 60_000, - }); +const NO_TOC_ENTRIES: t.TocEntry[] = []; -function resolvedConfigOptions(scope: t.ScopeSelection) { - const principalType = scope.type === 'SCOPE' ? scope.scope.principalType : null; - const principalId = scope.type === 'SCOPE' ? scope.scope.principalId : null; - return queryOptions({ - queryKey: ['resolvedConfig', principalType, principalId] as const, - queryFn: () => - getResolvedConfigFn({ - data: { - principalType: principalType!, - principalId: principalId!, - }, - }), - enabled: principalType != null && principalId != null, - staleTime: 60_000, - }); -} +/** Keys AppService adds to the resolved config that are runtime state, not settings. */ +const APP_SERVICE_INTERNAL_KEYS = new Set(['paths', 'config', 'availableTools']); -export function ConfigPage({ initialTab, highlightField, initialScope }: t.ConfigPageProps) { +export function ConfigPage({ + initialTab, + highlightField, + initialScope, + customizedOnly = false, +}: t.ConfigPageProps) { const localize = useLocalize(); - const queryClient = useQueryClient(); - const { hasCapability } = useCapabilities(); - const canManageConfig = hasCapability(SystemCapabilities.MANAGE_CONFIGS); - const canAssignConfigs = hasCapability(SystemCapabilities.ASSIGN_CONFIGS) || canManageConfig; const navigate = useNavigate({ from: '/configuration/' }); const { tree: schemaTree } = routeApi.useLoaderData(); - /** Per-section permission map: { [sectionKey]: { canView, canEdit } } */ - const sectionPermissions = useMemo(() => { - const perms: Record = {}; - for (const section of schemaTree) { - perms[section.key] = { - canView: hasConfigCapability(hasCapability, section.key, 'read'), - canEdit: hasConfigCapability(hasCapability, section.key, 'manage'), - }; - } - return perms; - }, [schemaTree, hasCapability]); - - const { data: baseConfigData } = useQuery(baseConfigOptions); - const configValues = baseConfigData?.config ?? null; - const dbOverrides = baseConfigData?.dbOverrides; - const configuredFromBase = baseConfigData?.configuredFromBase; - const schemaDefaults = baseConfigData?.schemaDefaults ?? {}; - const flatBaseline = useMemo(() => flattenObject(configValues ?? {}), [configValues]); - const [editedValues, setEditedValues] = useState({}); - const [touchedPaths, setTouchedPaths] = useState>(() => new Set()); - const [editSessionId, setEditSessionId] = useState(0); - - const fieldPaths = useMemo(() => collectFieldPaths(schemaTree), [schemaTree]); - const schemaPathSet = useMemo(() => new Set(fieldPaths), [fieldPaths]); - - const configuredPaths = useMemo(() => { - const paths = new Set(); - if (configuredFromBase) { - for (const p of configuredFromBase) paths.add(p); - } - if (dbOverrides) { - for (const p of Object.keys(flattenObject(dbOverrides))) paths.add(p); - } - return mapSecretPreviewPaths(paths, schemaPathSet); - }, [configuredFromBase, dbOverrides, schemaPathSet]); - - const dbOverridePaths = useMemo(() => { - if (!dbOverrides) return new Set(); - return mapSecretPreviewPaths(Object.keys(flattenObject(dbOverrides)), schemaPathSet); - }, [dbOverrides, schemaPathSet]); + const { + sectionPermissions, + visibleTabs, + activeTab, + handleTabChange, + canManageConfig, + canEditActiveTab, + permissions, + } = useConfigTabs({ + schemaTree, + initialTab: + initialTab ?? (highlightField?.includes('.') ? tabForPath(highlightField) : undefined), + }); - const baseRecordKeys = useMemo(() => { - const result: Record> = {}; - const yamlMcpKeys = baseConfigData?.yamlMcpKeys; - if (yamlMcpKeys && Array.isArray(yamlMcpKeys)) { - result.mcpServers = new Set(yamlMcpKeys); - } - return result; - }, [baseConfigData]); + const { + selectedScope, + selectScope, + isEditingScope, + editingScope, + scopeSelectorOpen, + setScopeSelectorOpen, + isResolvingScope = false, + scopeNotFound, + scopeLoadError, + retryScopes, + } = useScopeSelection({ initialScope }); + + const baseline = useConfigBaseline(selectedScope); + const isReady = baseline.status === 'ready' && !isResolvingScope; + const layers = useConfigLayers({ schemaTree, selectedScope, sectionPermissions }); + + const edits = useConfigEdits({ + isEditingScope, + flatBaseline: layers.flatBaseline, + scopeResolvedValues: layers.scopeResolvedValues, + baseActiveConfigValues: layers.baseActiveConfigValues, + schemaTree, + schemaPathSet: layers.schemaPathSet, + }); + const { editedValues, isDirty, discardEdits, resetEdits } = edits; - const hasUnmappedSections = useMemo( - () => - schemaTree.some( - (s: t.SchemaField) => !HIDDEN_SECTIONS.has(s.key) && !Object.hasOwn(SECTION_META, s.key), - ), - [schemaTree], + const activeConfigValues = usePendingConfigValues( + layers.displayConfigValues, + editedValues, + layers.valueLayers, ); - const { viewableTabIds, editableTabIds } = useMemo( - () => ({ - viewableTabIds: getTabsWithPermission( - schemaTree, - SECTION_META, - OTHER_TAB.id, - sectionPermissions, - 'canView', - HIDDEN_SECTIONS, - ), - editableTabIds: getTabsWithPermission( - schemaTree, - SECTION_META, - OTHER_TAB.id, - sectionPermissions, - 'canEdit', - HIDDEN_SECTIONS, - ), - }), - [schemaTree, sectionPermissions], + /** A list entry as the page shows it now and as the open profile inherits it from Base. */ + const scopeEntry = useCallback( + (collectionPath: string, identity: string) => { + const collection = getKeyedCollection(collectionPath); + if (!collection) return null; + const list = getValueAtPath(activeConfigValues, collectionPath); + const inheritedList = getValueAtPath(layers.valueLayers?.base ?? null, collectionPath); + const index = findEntryIndex(list, collection.key, identity); + const inheritedIndex = findEntryIndex(inheritedList, collection.key, identity); + return { + list: Array.isArray(list) ? list : [], + index, + inherited: Array.isArray(inheritedList) ? inheritedList[inheritedIndex] : undefined, + }; + }, + [activeConfigValues, layers.valueLayers], ); - const visibleTabs = useMemo(() => { - const allTabs = hasUnmappedSections ? [...CONFIG_TABS, OTHER_TAB] : CONFIG_TABS; - return allTabs.filter((tab) => viewableTabIds.has(tab.id)); - }, [hasUnmappedSections, viewableTabIds]); - - const activeTab = - initialTab && visibleTabs.some((tab) => tab.id === initialTab) - ? initialTab - : (visibleTabs[0]?.id ?? CONFIG_TABS[0].id); - - const handleTabChange = useCallback( - (newTab: string) => { - navigate({ search: (prev: Record) => ({ ...prev, tab: newTab }) }); + /** Removes the open profile's override of a list entry: back to the inherited entry, or out if it added it. */ + const revertScopeEntry = useCallback( + (collectionPath: string, identity: string) => { + const found = scopeEntry(collectionPath, identity); + if (!found || found.index < 0) return; + if (found.inherited === undefined) { + edits.handleFieldChange( + collectionPath, + found.list.filter((_, i) => i !== found.index), + ); + return; + } + edits.handleFieldChange(`${collectionPath}.${found.index}`, found.inherited); }, - [navigate], + [scopeEntry, edits.handleFieldChange], ); - const [importOpen, setImportOpen] = useState(false); - const [importSuccess, setImportSuccess] = useState(false); - const dismissTimer = useRef | undefined>(undefined); - useEffect(() => () => clearTimeout(dismissTimer.current), []); - - const [showConfiguredOnly, setShowConfiguredOnly] = useState(false); - - const [scopeSelectorOpen, setScopeSelectorOpen] = useState(false); - const [selectedScope, setSelectedScope] = useState({ type: 'BASE' }); - - const handleScopeChange = useCallback( - (newSelection: t.ScopeSelection) => { - if (Object.keys(editedValues).length > 0) { - if (!window.confirm(localize('com_config_unsaved_leave'))) return; - setEditedValues({}); - setTouchedPaths(new Set()); - } - setEditSessionId((id) => id + 1); - setConfirmSaveOpen(false); - setSelectedScope(newSelection); - const scopeId = - newSelection.type === 'SCOPE' && newSelection.scope._id - ? newSelection.scope._id - : undefined; - if (scopeId) { - localStorage.setItem(LAST_SCOPE_KEY, scopeId); - } else { - localStorage.removeItem(LAST_SCOPE_KEY); - } - navigate({ search: (prev: Record) => ({ ...prev, scope: scopeId }) }); + const isScopeEntryPending = useCallback( + (collectionPath: string, identity: string) => { + const found = scopeEntry(collectionPath, identity); + if (!found) return false; + const current = found.list[found.index]; + if (found.inherited === undefined) return found.index < 0; + return current !== undefined && isConfigValueEqual(current, found.inherited); }, - [editedValues, localize, navigate], + [scopeEntry], ); - const savedScope = useRef(localStorage.getItem(LAST_SCOPE_KEY) ?? undefined); - const scopeToRestore = initialScope ?? savedScope.current; - const { data: allScopes } = useQuery({ - ...availableScopesOptions, - enabled: !!scopeToRestore, + const tabBadges = useTabBadges({ + schemaTree, + visibleTabs, + activeConfiguredPaths: layers.activeConfiguredPaths, + activeConfigValues, + editedValues, + overridePaths: layers.overridePaths, + listOverrideCounts: layers.listOverrideCounts, + isEditingScope, }); - const initialScopeApplied = useRef(false); - useEffect(() => { - if (scopeToRestore && allScopes && !initialScopeApplied.current) { - const match = - allScopes.find((s) => s._id === scopeToRestore) ?? - (() => { - const [type, ...rest] = scopeToRestore.split(':'); - const id = rest.join(':'); - return allScopes.find( - (s) => s.principalType === (type as PrincipalType) && s.principalId === id, - ); - })(); - if (match) { - initialScopeApplied.current = true; - setSelectedScope({ type: 'SCOPE', scope: match }); - if (!initialScope) { - navigate({ search: (prev: Record) => ({ ...prev, scope: match._id }) }); - } - } - } - }, [scopeToRestore, allScopes, initialScope, navigate]); - - const isEditingScope = selectedScope.type === 'SCOPE'; - const editingScope: t.ConfigScope | undefined = - selectedScope.type === 'SCOPE' ? selectedScope.scope : undefined; - - const { data: profileMap = {} } = useQuery(profileMapOptions(fieldPaths)); - - const handleProfileChange = useCallback(() => { - queryClient.invalidateQueries({ queryKey: ['profileMap'] }); - queryClient.invalidateQueries({ queryKey: ['resolvedConfig'] }); - }, [queryClient]); - - const { data: resolvedData } = useQuery(resolvedConfigOptions(selectedScope)); - const scopeChangedPaths = resolvedData?.changedPaths ?? null; - const scopeResolvedValues = resolvedData?.resolvedConfig ?? null; - const scopeConfigValues = useMemo(() => { - if (!isEditingScope || !scopeResolvedValues) return null; - return unflattenObject(scopeResolvedValues) as Record; - }, [isEditingScope, scopeResolvedValues]); - - const baseActiveConfigValues = isEditingScope ? scopeConfigValues : configValues; - - const activeConfigValues = useMemo(() => { - if (!baseActiveConfigValues) return baseActiveConfigValues; - const indexedEdits = Object.entries(editedValues).filter(([k]) => /\.\d+$/.test(k)); - if (indexedEdits.length === 0) return baseActiveConfigValues; - return mergeIndexedArrayEdits(baseActiveConfigValues, indexedEdits); - }, [baseActiveConfigValues, editedValues]); - - const scopeConfiguredPaths = useMemo(() => { - if (!scopeChangedPaths) return new Set(); - return mapSecretPreviewPaths(scopeChangedPaths, schemaPathSet); - }, [scopeChangedPaths, schemaPathSet]); + const setCustomizedOnly = useCallback( + (value: boolean) => + navigate({ + search: (prev: t.ConfigSearchParams) => ({ ...prev, customized: value ? 1 : undefined }), + replace: true, + }), + [navigate], + ); + const showAllSettings = useCallback(() => setCustomizedOnly(false), [setCustomizedOnly]); - const scopeChangedPathsMapped = useMemo(() => { - if (!scopeChangedPaths) return null; - return Array.from(mapSecretPreviewPaths(scopeChangedPaths, schemaPathSet)); - }, [scopeChangedPaths, schemaPathSet]); + const [tabFilter, setTabFilter] = useState({ tab: activeTab, text: '' }); + const filterText = tabFilter.tab === activeTab ? tabFilter.text : ''; + const setFilterText = useCallback( + (text: string) => setTabFilter({ tab: activeTab, text }), + [activeTab], + ); + const clearFilter = useCallback(() => setFilterText(''), [setFilterText]); - const { data: langfuseConnection } = useQuery({ - queryKey: LANGFUSE_CONNECTION_QUERY_KEY, - queryFn: () => getLangfuseConnectionFn(), - enabled: - !isEditingScope && - schemaTree.some((section) => section.key === 'langfuse') && - sectionPermissions.langfuse?.canEdit === true, - retry: false, - refetchOnWindowFocus: false, + const save = useConfigSave({ + schemaTree, + schemaPathSet: layers.schemaPathSet, + editingScope, + layers, + edits, }); + const { cancelReview } = save; - const baseConfiguredPaths = useMemo( - () => withLangfuseConfiguredPath(configuredPaths, langfuseConnection?.configured === true), - [configuredPaths, langfuseConnection?.configured], + /** "Go to field" and error jumps from the review may target a setting the tab filter hides. */ + const goToTab = useCallback( + (tabId: string) => { + clearFilter(); + handleTabChange(tabId); + }, + [clearFilter, handleTabChange], ); + const { scopes: profiles } = useScopeIndex(); + const review = useChangeReview({ + schemaTree, + editingScope, + layers, + edits, + save, + onTabChange: goToTab, + profiles, + }); - const activeConfiguredPaths = isEditingScope ? scopeConfiguredPaths : baseConfiguredPaths; - - const tabConfiguredCounts = useMemo(() => { - if (activeConfiguredPaths.size === 0) return {}; - const schemaKeyToTabs: Record = {}; - for (const [metaKey, meta] of Object.entries(SECTION_META)) { - if (meta.schemaKey) { - (schemaKeyToTabs[meta.schemaKey] ??= []).push(meta.tab); - } - if (!meta.schemaKey) { - (schemaKeyToTabs[metaKey] ??= []).push(meta.tab); - } - } - - const counts: Record = {}; - for (const tab of visibleTabs) { - if (tab.id === 'mcp' && activeConfigValues) { - const mcpValue = activeConfigValues.mcpServers; - counts[tab.id] = - mcpValue && typeof mcpValue === 'object' && !Array.isArray(mcpValue) - ? Object.keys(mcpValue).length - : 0; - continue; - } - - if (tab.id === 'custom' && activeConfigValues) { - const endpointsValue = activeConfigValues.endpoints as - | Record - | undefined; - const customArray = endpointsValue?.custom; - counts[tab.id] = Array.isArray(customArray) ? customArray.length : 0; - continue; - } - - const tabSections = schemaTree.filter((section: t.SchemaField) => { - if (HIDDEN_SECTIONS.has(section.key)) return false; - if (tab.id === OTHER_TAB.id) return !Object.hasOwn(SECTION_META, section.key); - return schemaKeyToTabs[section.key]?.includes(tab.id) ?? false; - }); - let count = 0; - for (const section of tabSections) { - const paths = section.children?.length - ? collectFieldPaths(section.children, section.key) - : [section.key]; - for (const p of paths) { - if (tab.id === 'providers' && p.startsWith('endpoints.custom')) continue; - if (activeConfiguredPaths.has(p)) count++; - } - } - counts[tab.id] = count; - } - return counts; - }, [activeConfiguredPaths, activeConfigValues, visibleTabs, schemaTree]); - - const scopeBaseline = useMemo(() => { - if (!isEditingScope) return flatBaseline; - return scopeResolvedValues ?? {}; - }, [isEditingScope, flatBaseline, scopeResolvedValues]); + const resetBase = useResetBaseConfig({ onReset: edits.resetEdits }); + const baseOverrides = useBaseOverrides({ schemaTree, layers, profiles }); - /** Container paths inferred from leaf baselines, used to tell apart subtree-deletes from no-op writes. */ - const baselineIntermediates = useMemo(() => { - const set = new Set(); - for (const leaf of Object.keys(scopeBaseline)) { - const parts = leaf.split('.'); - for (let i = 1; i < parts.length; i++) { - set.add(parts.slice(0, i).join('.')); - } - } - return set; - }, [scopeBaseline]); - - /** Container paths walked directly off the structured config, so an orphaned `{}` entry whose flatten dropped (or never produced) any leaf is still recognized as a real subtree-delete target. */ - const baselineContainerPaths = useMemo(() => { - const set = new Set(); - const walk = (obj: unknown, prefix: string): void => { - if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) return; - for (const k of Object.keys(obj as Record)) { - const path = prefix ? `${prefix}.${k}` : k; - const v = (obj as Record)[k]; - if (v !== null && typeof v === 'object' && !Array.isArray(v)) { - set.add(path); - walk(v, path); - } - } - }; - walk(baseActiveConfigValues, ''); - return set; - }, [baseActiveConfigValues]); + const imports = useConfigImport({ + writeChanges: save.writeChanges, + describeSkipped: save.describeSkipped, + }); - const handleFieldChange = useCallback( - (path: string, value: t.ConfigValue) => { - setTouchedPaths((prev) => { - if (prev.has(path)) return prev; - const next = new Set(prev); - next.add(path); - return next; - }); - setEditedValues((prev) => { - return applyConfigEdit( - prev, - path, - value, - scopeBaseline, - baselineIntermediates, - baselineContainerPaths, - ); - }); + /** Re-selecting the open scope keeps the edits; any other goes through the guard. */ + const handleScopeChange = useCallback( + (newSelection: t.ScopeSelection) => { + const current = editingScope ? formatScopeParam(editingScope) : undefined; + const next = newSelection.type === 'SCOPE' ? formatScopeParam(newSelection.scope) : undefined; + if (current === next && !scopeNotFound) return; + selectScope(newSelection); }, - [scopeBaseline, baselineIntermediates, baselineContainerPaths], + [editingScope, scopeNotFound, selectScope], + ); + const scopeActions = useMemo( + () => ({ selectScope: handleScopeChange, editingScope }), + [handleScopeChange, editingScope], ); - /** - * Removes `path` from `editedValues`/`touchedPaths` directly, bypassing - * `applyConfigEdit`'s baseline-match diffing. Abandoning an in-progress - * SecretField replacement (Cancel) is never a real edit — representing it - * as `onChange(path, undefined)` would mean the same thing as a real reset - * whenever a scope-resolved baseline happens to also read as empty. - */ - const handleDiscardField = useCallback((path: string) => { - setEditedValues((prev) => { - if (!(path in prev)) return prev; - const next = { ...prev }; - delete next[path]; - return next; - }); - setTouchedPaths((prev) => { - if (!prev.has(path)) return prev; - const next = new Set(prev); - next.delete(path); - return next; - }); - }, []); - - const isDirty = Object.keys(editedValues).length > 0; - - const pendingResets = useMemo(() => { - const resets = new Set(); - for (const [k, v] of Object.entries(editedValues)) { - if (v === undefined) resets.add(k); + const scopeKey = editingScope ? formatScopeParam(editingScope) : 'base'; + const lastScope = useRef({ key: scopeKey, name: editingScope?.name, param: initialScope }); + useEffect(() => { + const previous = lastScope.current; + if (previous.key === scopeKey) return; + lastScope.current = { key: scopeKey, name: editingScope?.name, param: initialScope }; + /** The open profile was deleted elsewhere (the link still names it): its edits can't be saved. */ + if (isDirty && scopeNotFound != null && previous.param === initialScope) { + notifyWarning( + localize('com_config_scope_vanished', { + name: previous.name ?? scopeNotFound, + count: Math.max(review.model.count, 1), + }), + ); } - return resets; - }, [editedValues]); + resetEdits(); + cancelReview(); + }, [ + scopeKey, + editingScope, + initialScope, + scopeNotFound, + isDirty, + review.model.count, + localize, + resetEdits, + cancelReview, + ]); - useBlocker({ - shouldBlockFn: ({ current, next }) => { - if (!isDirty) return false; - if (current.pathname === next.pathname) return false; - return !window.confirm(localize('com_config_unsaved_leave')); + const { data: availableScopes } = useQuery(availableScopesOptions); + const describeScope = useCallback( + (scope: string | undefined) => { + const param = parseScopeParam(scope); + if (!param) return localize('com_scope_base_config'); + return findScopeByParam(availableScopes ?? [], param)?.name ?? scope ?? ''; }, + [availableScopes, localize], + ); + const guard = useNavigationGuard({ + isDirty, + discardEdits, + openReview: save.openReview, + reviewOpen: save.confirmSaveOpen, + saving: save.saving, + describeScope, }); + const backToBase = useCallback(() => selectScope({ type: 'BASE' }), [selectScope]); + + /** The preview opens on whom the page edits: the open profile's principal, or everyone. */ + const [previewOpen, setPreviewOpen] = useState(false); + const previewTarget = useMemo( + (): t.PreviewTarget => + editingScope + ? { type: 'PRINCIPAL', principal: toScopeCandidate(editingScope) } + : { type: 'BASE' }, + [editingScope], + ); - const [confirmSaveOpen, setConfirmSaveOpen] = useState(false); - const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(null); - - const handleDiscard = useCallback(() => { - setEditedValues({}); - setTouchedPaths(new Set()); - setEditSessionId((id) => id + 1); - }, []); - - const clearEdits = useCallback(() => { - setEditedValues({}); - setTouchedPaths(new Set()); - setEditSessionId((id) => id + 1); - setConfirmSaveOpen(false); - setSaving(false); - setSaveError(null); - notifySuccess(localize('com_config_saved')); - }, [localize]); - - const invalidateAndResetBase = useCallback(() => { - queryClient.invalidateQueries({ queryKey: ['baseConfig'] }); - clearEdits(); - }, [queryClient, clearEdits]); - - const invalidateAndResetScope = useCallback(() => { - queryClient.invalidateQueries({ queryKey: ['resolvedConfig'] }); - queryClient.invalidateQueries({ queryKey: ['profileMap'] }); - queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); - clearEdits(); - }, [queryClient, clearEdits]); + const [scrollEl, setScrollEl] = useState(null); + const [tocEl, setTocEl] = useState(null); + const setActiveSection = useActiveSection(scrollEl, tocEl, activeTab); - const importMutation = useMutation({ - mutationFn: (config: Record) => importBaseConfigFn({ data: { config } }), - onError: (err: Error) => notifyError(err.message), - onSuccess: invalidateAndResetBase, + const tabSections = useTabSections({ + schemaTree, + activeTab, + activeConfigValues, + sectionPermissions, + editedValues, }); + const sectionsForActiveTab = useScopeGatedSections(tabSections, isEditingScope); - const [resetBaseOpen, setResetBaseOpen] = useState(false); - const [resettingBase, setResettingBase] = useState(false); - const [resetBaseError, setResetBaseError] = useState(null); - - const handleResetBaseConfig = useCallback(async () => { - if (resettingBase) return; - setResettingBase(true); - setResetBaseError(null); - try { - await resetBaseConfigFn(); - /** resolvedConfig holds each scope's own overrides (not a base merge), so a - * base reset doesn't make it stale on its own — but base-derived data - * (schemaDefaults, base values used for MCP inheritance) feeds scope mode, - * so flush it too, consistent with how scope saves invalidate. */ - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['baseConfig'] }), - queryClient.invalidateQueries({ queryKey: ['resolvedConfig'] }), - ]); - setEditedValues({}); - setTouchedPaths(new Set()); - setEditSessionId((id) => id + 1); - setResettingBase(false); - setResetBaseOpen(false); - notifySuccess(localize('com_config_reset_base_success')); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setResettingBase(false); - setResetBaseError(message); - notifyError(message); - } - }, [resettingBase, queryClient, localize]); + const clearFieldParam = useCallback( + () => + navigate({ + search: (prev: t.ConfigSearchParams) => ({ ...prev, tab: activeTab, field: undefined }), + replace: true, + }), + [navigate, activeTab], + ); + useRevealField({ + container: scrollEl, + field: highlightField, + sections: sectionsForActiveTab, + ready: isReady, + onDone: clearFieldParam, + }); - const handleResetField = useCallback((fieldPath: string) => { - startTransition(() => { - setTouchedPaths((prev) => { - if (prev.has(fieldPath)) return prev; - const next = new Set(prev); - next.add(fieldPath); - return next; - }); - setEditedValues((prev) => ({ ...prev, [fieldPath]: undefined })); + const hasFilter = filterText.trim() !== ''; + /** + * The tab filter matches the pending values and the saved ones: a row the + * admin edits while it is matched (an entry renamed) keeps matching, so it + * never disappears mid-edit, and an entry pending deletion stays findable + * (with its Undo). New entries match by their pending values. + */ + const savedValues = isDirty ? layers.displayConfigValues : null; + const searchIndex = useConfigSearchIndex( + schemaTree, + hasFilter ? activeConfigValues : null, + localize, + hasFilter, + ); + const savedIndex = useConfigSearchIndex( + schemaTree, + hasFilter ? savedValues : null, + localize, + hasFilter && savedValues != null, + ); + const filterHits = useMemo(() => { + if (!hasFilter) return undefined; + const tabSectionIds = new Set(sectionsForActiveTab.map((section) => section.id)); + const onTab = (item: t.ConfigSearchItem) => + item.tab === activeTab && tabSectionIds.has(item.sectionId); + const hits = searchConfigIndex(searchIndex.filter(onTab), filterText, 500); + if (savedIndex.length === 0) return hits; + const seen = new Set(hits.map((hit) => hit.item.path)); + /** + * A saved hit inside a keyed list names a saved position; the page lists + * pending positions, so it moves with its entry (by identity). An entry + * pending deletion keeps its list shown (with its pending-deletion card) + * without matching whichever entry now sits at its old position. + */ + const saved = searchConfigIndex(savedIndex.filter(onTab), filterText, 500).flatMap((hit) => { + const path = moveEntryPath(hit.item.path, savedValues, activeConfigValues); + if (seen.has(path)) return []; + seen.add(path); + return [path === hit.item.path ? hit : { ...hit, item: { ...hit.item, path } }]; }); - }, []); - - const handleConfirmSave = useCallback(async () => { - if (saving) return; - const { touched, saves, resets } = buildSavePayload(touchedPaths, editedValues, schemaPathSet); - if (touched.length === 0) 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 = (() => { - const v = baseActiveConfigValues?.mcpServers; - if (v && typeof v === 'object' && !Array.isArray(v)) { - return v as Record; - } - return {}; - })(); - const mcpEdits: Array<[string, t.ConfigValue]> = touched - .filter((p) => p.startsWith('mcpServers.')) - .map((p) => [p, editedValues[p]] as [string, t.ConfigValue]); - /** 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; - if (source && typeof source === 'object' && !Array.isArray(source)) { - return source as Record; - } - return undefined; - })(); - if (mcpEdits.length > 0) { - const mcpErrors = validateMcpCrossField(mcpBaseline, mcpEdits, mcpResetFallback); - if (mcpErrors.length > 0) { - const { entryKey, missingField } = mcpErrors[0]; - const message = localize('com_config_mcp_invalid_after_edit', { - entry: entryKey, - field: missingField, - }); - setSaveError(message); - notifyError(message); - return; - } - } - - const inheritedMcpKeys = (() => { - const source = isEditingScope ? configValues?.mcpServers : undefined; - if (source && typeof source === 'object' && !Array.isArray(source)) { - return new Set(Object.keys(source as Record)); - } - return new Set(); - })(); - - setSaving(true); - 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(); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setSaving(false); - setSaveError(message); - notifyError(message); - } + return [...hits, ...saved]; }, [ - touchedPaths, - editedValues, - schemaPathSet, - saving, - isEditingScope, - baseActiveConfigValues, - configValues, - baseConfigData, - localize, - editingScope, - invalidateAndResetScope, - invalidateAndResetBase, + hasFilter, + searchIndex, + savedIndex, + savedValues, + activeConfigValues, + sectionsForActiveTab, + activeTab, + filterText, ]); + const filterSectionIds = useMemo( + () => filterHits && new Set(filterHits.map((hit) => hit.item.sectionId)), + [filterHits], + ); + /** Matching rows, indexed; a section's own hit counts only when none of its rows match. */ + const filterPaths = useMemo(() => { + if (!filterHits) return undefined; + const withRows = new Set( + filterHits.filter((hit) => hit.item.kind !== 'section').map((hit) => hit.item.sectionId), + ); + return createPathIndex( + filterHits + .filter((hit) => hit.item.kind !== 'section' || !withRows.has(hit.item.sectionId)) + .map((hit) => hit.item.path), + ); + }, [filterHits]); + /** A jump (search, deep link) to a setting the tab filter hides clears the filter first. */ + useEffect(() => { + if (highlightField && filterPaths && !isRowVisible(filterPaths, highlightField)) clearFilter(); + }, [highlightField, filterPaths, clearFilter]); + + /** "Overridden by Global →" chips, on any tab, open the Global overrides setting that wins (D11). */ + const revealGlobal = useCallback( + (path: string) => + navigate({ + search: (prev: t.ConfigSearchParams) => ({ ...prev, tab: tabForPath(path), field: path }), + }), + [navigate], + ); - const serializedEditedValues = useMemo(() => { - const result: t.FlatConfigMap = {}; - for (const [k, v] of Object.entries(editedValues)) { - result[k] = stripSecretPreviewValues(deepSerializeKVPairs(v), k, schemaPathSet); - } - return result; - }, [editedValues, schemaPathSet]); - - 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]; - } - if (current !== undefined) - result[path] = stripSecretPreviewValues(current, path, schemaPathSet); - } - return result; - }, [editedValues, flatBaseline, isEditingScope, scopeBaseline, configValues, schemaPathSet]); - - const [importSuccessMessage, setImportSuccessMessage] = useState(null); - - const showImportSuccess = useCallback((message?: string) => { - setImportSuccessMessage(message ?? null); - setImportSuccess(true); - clearTimeout(dismissTimer.current); - 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, + const configuredPaths = layers.activeConfiguredPaths; + const revealSetting = useCallback( + (item: t.ConfigSearchItem) => { + const hidden = + customizedOnly && + ![...configuredPaths].some((p) => isPathUnder(p, item.path) || isPathUnder(item.path, p)); + navigate({ + search: (prev: t.ConfigSearchParams) => ({ + ...prev, + field: item.path, + ...(hidden && { customized: undefined }), }), - ); + replace: true, + }); }, - [queryClient, localize, showImportSuccess, schemaPathSet], + [navigate, customizedOnly, configuredPaths], ); - 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() }); - } - }, - [ - isEditingScope, - editingScope, - importMutation, - showImportSuccess, - handleImportAsProfile, - schemaPathSet, - ], + const tocSections = useMemo( + () => + filterSectionIds + ? sectionsForActiveTab.filter((section) => filterSectionIds.has(section.id)) + : sectionsForActiveTab, + [sectionsForActiveTab, filterSectionIds], ); - - const highlightRef = useHighlightRef(highlightField); - const [scrollEl, setScrollEl] = useState(null); - const [tocEl, setTocEl] = useState(null); - const scrollCallbackRef = useCallback( - (el: HTMLDivElement | null) => { - setScrollEl(el); - highlightRef(el); - }, - [highlightRef], + const tocEntries = useMemo( + () => buildTocEntries(tocSections, localize, filterPaths ?? null), + [tocSections, localize, filterPaths], ); - const setActiveSection = useActiveSection(scrollEl, tocEl, activeTab); - - const canEditActiveTab = editableTabIds.has(activeTab); - - /** Route-level gating ensures canView; canEdit reflects per-tab manage capability. */ - const permissions: t.ScopePermissions = useMemo( + const jumpTargets = useMemo(() => toJumpTargets(tocEntries), [tocEntries]); + const dirtyPaths = review.model.changedPaths; + const errorPaths = useMemo(() => Object.keys(save.fieldErrors), [save.fieldErrors]); + const restateEditable = canEditActiveTab && scopeNotFound == null; + const restates = useMemo( () => ({ - canView: true, - canEdit: canEditActiveTab, - canAssign: canAssignConfigs, + restates: edits.restateEdits, + onRestate: restateEditable ? edits.handleRestateCredential : undefined, }), - [canEditActiveTab, canAssignConfigs], + [edits.restateEdits, edits.handleRestateCredential, restateEditable], + ); + const entryRenames = useMemo( + () => ({ renames: edits.renameEdits, copies: edits.copyEdits }), + [edits.renameEdits, edits.copyEdits], + ); + /** "Jump to…" scrolls to the section and puts focus on its heading, as the list it was chosen from goes away. */ + const jumpTo = useCallback( + (id: string) => { + const entry = findTocEntry(tocEntries, id); + if (!entry || !scrollEl) return; + setActiveSection(id); + const isSection = entry.id === `section-${entry.sectionId}`; + revealConfigPath( + scrollEl, + { path: entry.path, sectionId: isSection ? undefined : entry.sectionId }, + { align: 'start', focus: 'heading', highlight: false }, + ); + }, + [tocEntries, scrollEl, setActiveSection], ); - const sectionsForActiveTab = useMemo((): t.ConfigSectionConfig[] => { - // Collect virtual section entries (those with schemaKey) that target this tab - const virtualEntries = Object.entries(SECTION_META).filter( - ([, m]) => m.schemaKey && m.tab === activeTab, - ); - - const directSections = schemaTree - .filter((section: t.SchemaField) => { - if (HIDDEN_SECTIONS.has(section.key)) return false; - if (activeTab === OTHER_TAB.id) return !Object.hasOwn(SECTION_META, section.key); - return SECTION_META[section.key]?.tab === activeTab; - }) - .map((section: t.SchemaField) => { - const meta = SECTION_META[section.key]; - const children = section.children ?? []; - const hasStructuredChildren = - (section.isObject || section.type === 'record') && children.length > 0; - return { - id: section.key, - titleKey: meta?.titleKey ?? `com_config_section_${section.key}`, - descriptionKey: meta?.descriptionKey, - fields: hasStructuredChildren ? children : [], - ...(!hasStructuredChildren && { sectionField: section }), - ...(section.key === 'interface' && { - bannerText: localize('com_config_interface_permissions_info'), - }), - }; - }); - - // Add virtual sections — these reference another schema section's data - // but render under a different tab with their own section renderer. - const virtualSections = virtualEntries.flatMap(([metaKey, meta]) => { - const schemaSection = schemaTree.find((s: t.SchemaField) => s.key === meta.schemaKey); - if (!schemaSection) return []; - const hasStructuredChildren = - (schemaSection.isObject || schemaSection.type === 'record') && - schemaSection.children && - schemaSection.children.length > 0; - return [ - { - id: metaKey, - schemaKey: meta.schemaKey, - titleKey: meta.titleKey, - descriptionKey: meta.descriptionKey, - fields: hasStructuredChildren ? (schemaSection.children ?? []) : [], - ...(!hasStructuredChildren && { sectionField: schemaSection }), - }, - ]; - }); - - const allSections: t.ConfigSectionConfig[] = [...directSections, ...virtualSections].filter( - (s) => { - const permKey = 'schemaKey' in s && s.schemaKey ? s.schemaKey : s.id; - return sectionPermissions[permKey]?.canView === true; - }, - ); - - // Custom Endpoints tab: show configured endpoint names in TOC - if (activeTab === 'custom' && activeConfigValues) { - for (const section of allSections) { - const dataKey = section.schemaKey ?? section.id; - const sectionValue = activeConfigValues[dataKey] as - | Record - | undefined; - const customArray = sectionValue?.custom; - section.titleKey = 'com_config_tab_custom_endpoints'; - if (Array.isArray(customArray) && customArray.length > 0) { - section.tocItems = customArray.map((entry, i) => { - const obj = - entry && typeof entry === 'object' && !Array.isArray(entry) - ? (entry as Record) - : {}; - const name = - typeof obj.name === 'string' && obj.name - ? obj.name - : localize('com_config_entry_n', { n: String(i + 1) }); - return { - id: `section-${dataKey}-custom-${i}`, - label: name, - dataPath: `${dataKey}.custom`, - }; - }); - } - } - } - - // MCP Servers tab: show configured server names in TOC - if (activeTab === 'mcp' && activeConfigValues) { - for (const section of allSections) { - if (section.id !== 'mcpServers') continue; - const dataKey = section.schemaKey ?? section.id; - const mcpValue = activeConfigValues[dataKey]; - if (mcpValue && typeof mcpValue === 'object' && !Array.isArray(mcpValue)) { - const serverKeys = Object.keys(mcpValue as Record); - if (serverKeys.length > 0) { - section.tocItems = serverKeys.map((name) => ({ - id: `section-mcpServers-${encodeURIComponent(name)}`, - label: name, - dataPath: `mcpServers.${name}`, - })); - } - } - } - } - - // AI Providers tab: show provider names in TOC (excluding 'custom') - if (activeTab === 'providers') { - for (const section of allSections) { - const providerFields = section.fields.filter( - (f) => f.key !== 'custom' && f.children && f.children.length > 0, - ); - if (providerFields.length > 0) { - const dataKey = section.schemaKey ?? section.id; - section.tocItems = providerFields.map((f) => ({ - id: `section-${dataKey}.${f.key}`, - label: localize(`com_config_field_${f.key}`), - })); - } - } + /** 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 allSections; - }, [schemaTree, activeTab, activeConfigValues, localize, sectionPermissions]); + return { fields: schemaTree, value }; + }, [activeTab, activeConfigValues, schemaTree]); const renderBanner = () => { - if (importSuccess) { - return ( - - ); - } - return null; + if (!scopeNotFound) return null; + return ( + selectScope({ type: 'SCOPE', scope })} + /> + ); }; const banner = renderBanner(); + const importTitle = (() => { + if (!canManageConfig) { + return localize('com_cap_no_permission', { cap: SystemCapabilities.MANAGE_CONFIGS }); + } + if (isDirty) return localize('com_config_shell_import_dirty'); + return undefined; + })(); + const resetBaseTitle = (() => { if (!canManageConfig) { return localize('com_cap_no_permission', { cap: SystemCapabilities.MANAGE_CONFIGS }); @@ -953,212 +527,210 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi })(); return ( -
-
- {banner &&
{banner}
} - setImportOpen(true)} - showReset={!isEditingScope && dbOverridePaths.size > 0} - resetDisabled={isDirty || !canManageConfig} - resetTitle={resetBaseTitle} - onResetClick={() => { - setResetBaseError(null); - setResetBaseOpen(true); - }} - showScope={permissions.canView} - scopeSelection={selectedScope} - onScopeClick={() => setScopeSelectorOpen(true)} - /> - -
- -
-
- {activeTab !== 'custom' && ( -
+ + +
+
+ {banner &&
{banner}
} + imports.setImportOpen(true)} + showReset={isReady && !isEditingScope && !scopeNotFound && baseOverrides.count > 0} + resetLabel={localize('com_config_review_reset_button', { + count: baseOverrides.count, + })} + resetDisabled={isDirty || !canManageConfig} + resetTitle={resetBaseTitle} + onResetClick={resetBase.openResetBase} + showPreview={isReady && permissions.canView} + onPreviewClick={() => setPreviewOpen(true)} + showScope={permissions.canView} + scopeSelection={selectedScope} + scopeLoading={isResolvingScope && !scopeLoadError} + scopeUnavailable={scopeLoadError != null} + onScopeClick={() => setScopeSelectorOpen(true)} + /> + + {isReady && editingScope && ( + paths.forEach(edits.handleResetField) + : undefined + } + onUnhide={permissions.canAssign ? edits.handleUnhideField : undefined} + visibility={edits.visibilityEdits} + fields={schemaTree} + config={activeConfigValues} + onRevertEntry={permissions.canAssign ? revertScopeEntry : undefined} + isEntryPending={isScopeEntryPending} + /> + )} + {isReady && ( 0} + filter={filterText} + onFilterChange={setFilterText} + filterHits={filterHits} + onFilterSelect={revealSetting} + customizedOnly={customizedOnly} + onCustomizedOnlyChange={setCustomizedOnly} + jumpTargets={jumpTargets} + onJump={jumpTo} + nothingCustomized={layers.activeConfiguredPaths.size === 0} /> + )} +
+ +
+
+
+ {baseline.status === 'error' && ( + + )} + {baseline.status !== 'error' && scopeLoadError && ( + + )} + {baseline.status !== 'error' && + !scopeLoadError && + (baseline.status === 'pending' || isResolvingScope) && } + {isReady && ( + + + + + + + + + + + + + + )} +
- )} -
- notifyError(message)} - editSessionId={editSessionId} +
-
- -
- - {isDirty && canEditActiveTab && ( - setConfirmSaveOpen(true)} - /> - )} - - setConfirmSaveOpen(false)} - /> - notifyError(msg)} - /> - - setImportOpen(false)} - onImport={handleImport} - onImportAsProfile={handleImportAsProfile} - /> - - { - if (resettingBase) return; - setResetBaseOpen(false); - setResetBaseError(null); - }} - /> -
- ); -} - -function HeaderActions({ - showImport, - importDisabled, - importTitle, - onImportClick, - showReset, - resetDisabled, - resetTitle, - onResetClick, - showScope, - scopeSelection, - onScopeClick, -}: { - showImport: boolean; - importDisabled: boolean; - importTitle?: string; - onImportClick: () => void; - showReset: boolean; - resetDisabled: boolean; - resetTitle?: string; - onResetClick: () => void; - showScope: boolean; - scopeSelection: t.ScopeSelection; - onScopeClick: () => void; -}) { - const localize = useLocalize(); - const [portalTarget, setPortalTarget] = useState(null); - - useEffect(() => { - setPortalTarget(document.getElementById('header-actions-portal')); - }, []); - - const content = ( - <> - {showImport && ( - - )} - {showReset && ( - - )} - {showScope && } - + {isReady && } + + + + + setPreviewOpen(false)} + initialTarget={previewTarget} + /> + + imports.setImportOpen(false)} + currentScope={editingScope} + onImport={imports.handleImport} + schemaTree={schemaTree} + schemaPathSet={layers.schemaPathSet} + /> + + + + +
+ + ); - - if (portalTarget) return createPortal(content, portalTarget); - return null; } diff --git a/src/components/configuration/ConfigRow.tsx b/src/components/configuration/ConfigRow.tsx index 1e453d69..7f596612 100644 --- a/src/components/configuration/ConfigRow.tsx +++ b/src/components/configuration/ConfigRow.tsx @@ -1,10 +1,47 @@ -import { Icon } from '@clickhouse/click-ui'; +import type { ReactNode } from 'react'; import type * as t from '@/types'; -import { SectionControls } from './SectionControls'; -import { SectionHeader } from './SectionHeader'; -import { useLocalize } from '@/hooks'; -import { cn } from '@/utils'; +import { + useLocalize, + useFieldState, + useRevertLabels, + useValuesScope, + useFieldProfiles, + useHasValueLayers, + useRefocusWhenGone, +} from '@/hooks'; +import { + ShadowBadge, + showsShadow, + sourceLabel, + FieldProvenance, + formatConfigValue, +} from './values'; +import { activeConfigTab, fieldControl } from '@/utils'; +import { ProfileIndicator } from './ProfileIndicator'; +import { StateChip } from '@/components/shared'; +import { inferControlWidth } from './width'; +import { SettingsRow } from './SettingsRow'; +import { getFieldMeta } from './meta'; +/** Provenance from the field state, falling back to the row's own flags outside a values provider. */ +function resolveSource( + state: t.FieldState, + isDbOverride: boolean | undefined, + isConfigured: boolean | undefined, +): t.ProvenanceSource { + if (state.source !== 'default') return state.source; + if (isDbOverride) return 'admin'; + if (isConfigured) return 'yaml'; + return 'default'; +} + +/** + * A schema field's row (VS-1): full contrast whatever its state, with chips + * for where the value comes from (librechat.yaml, Override, a profile, + * Inherited; WS4's provenance chip with its layer stack) or what is pending + * (Edited with Undo, Pending revert, Error). Default values carry no chip; the + * "Default: …" hint under the label says so. + */ export function ConfigRow({ title, description, @@ -15,88 +52,198 @@ export function ConfigRow({ hidden, fieldId, fieldPath, + previewScope, previewChangedPaths, permissions, onResetField, + onDiscardField, isConfigured, isDbOverride, - isTouched, isPendingReset, defaultHint, + controlWidth, + required, + error, }: t.ConfigRowProps) { const localize = useLocalize(); - if (hidden) return null; - - const isChanged = fieldPath && previewChangedPaths?.includes(fieldPath); - const showReset = - !isPendingReset && - (isDbOverride || isChanged) && + const state = useFieldState(fieldPath ?? ''); + const hasLayers = useHasValueLayers(); + const revertLabels = useRevertLabels(fieldPath ?? '', title); + const overriddenBy = useFieldProfiles(fieldPath); + const valuesScope = useValuesScope(); + const isPending = Boolean(isPendingReset) || state.isPendingReset; + const isEdited = !isPending && state.isEdited; + const isChanged = fieldPath != null && previewChangedPaths?.includes(fieldPath); + /** A stored override LibreChat ignores can be removed even where the section is read-only (D5). */ + const canReset = + !isPending && + !isEdited && + (isDbOverride || state.isOverride || isChanged) && fieldPath && - !disabled && + (!disabled || state.ignoredOverride) && permissions?.canEdit && onResetField; + /** The reset takes its own button away: focus goes to the field it reset. */ + const requestRefocus = useRefocusWhenGone(Boolean(canReset), [ + () => fieldControl(fieldId), + activeConfigTab, + ]); + if (hidden) return null; + + const errorText = error ?? state.error; + const source = resolveSource(state, isDbOverride, isConfigured); + const undo = fieldPath && onDiscardField ? () => onDiscardField(fieldPath) : undefined; + const undoLabel = localize('com_config_ui_undo_change', { name: title }); + + /** Outside a values provider with layers, the row's own flags decide the chip. */ + const fallbackProvenanceChip = (): ReactNode => { + if (source === 'admin') { + return ( + + ); + } + if (source === 'scope') { + return ( + + ); + } + if (source === 'yaml') { + return ; + } + if (source === 'inherited' && state.effective !== undefined) { + return ( + + ); + } + return null; + }; + + /** What the value was before the pending change: for a pending hide, what the profile inherited. */ + const savedValue = (): t.RevertTarget | undefined => { + if (state.visibilityEdit === 'hide') return state.inherited; + if (state.isHidden) return undefined; + return { value: state.effective, source: state.source }; + }; + + /** The Edited chip keeps what the value was and where it came from ("Was 1,000,000 (librechat.yaml)", "Was hidden"). */ + const editedDescription = (): string => { + const unsaved = localize('com_config_ui_state_edited_desc'); + if (state.visibilityEdit === 'unhide') { + return `${unsaved}. ${localize('com_config_ui_state_edited_was_hidden')}`; + } + const was = savedValue(); + if (!fieldPath || !hasLayers || was?.value === undefined) return unsaved; + const description = localize('com_config_ui_state_edited_was', { + value: formatConfigValue(was.value, localize, fieldPath), + source: sourceLabel(was.source, localize, valuesScope), + }); + return `${unsaved}. ${description}`; + }; + + const stateChip = (): ReactNode => { + if (errorText) return ; + if (isPending) { + return ( + + ); + } + if (isEdited) { + return ( + <> + + {showsShadow(state) && ( + + )} + + ); + } + if (fieldPath && hasLayers) return ; + return fallbackProvenanceChip(); + }; + + const resetLabel = revertLabels?.label ?? localize('com_ui_reset'); + const resetAriaLabel = + revertLabels?.ariaLabel ?? + (isChanged + ? localize('com_a11y_reset_override', { name: title }) + : localize('com_a11y_reset_to_default', { name: title })); - const resetAction = showReset ? ( + const resetAction = canReset ? ( ) : null; - const pendingResetHint = isPendingReset ? ( - - {localize('com_config_pending_reset')} - - ) : null; - - const configuredDot = - isConfigured && !isPendingReset ? ( - + ) : undefined + } + actions={actions} + error={errorText} + required={required} + width={ + controlWidth ?? + (fieldPath ? getFieldMeta(fieldPath)?.width : undefined) ?? + inferControlWidth(fieldPath, state.effective) + } + marker={marker()} + /> ); } diff --git a/src/components/configuration/ConfigSection.tsx b/src/components/configuration/ConfigSection.tsx index 605107f8..21feef19 100644 --- a/src/components/configuration/ConfigSection.tsx +++ b/src/components/configuration/ConfigSection.tsx @@ -1,27 +1,60 @@ -import { Icon } from '@clickhouse/click-ui'; -import { useState, useCallback, useEffect, useRef } from 'react'; -import type { ReactNode } from 'react'; +import { useState, useContext, useCallback, useEffect, useId, useMemo, useRef } from 'react'; import type * as t from '@/types'; -import { useLocalize } from '@/hooks'; -import { cn } from '@/utils'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { SoleFieldContext } from '@/contexts/SettingsContext'; +import { useLocalize, useExpandSignal } from '@/hooks'; +import { SettingsSection } from './SettingsSection'; +import { CountBadge } from '@/components/shared'; + +/** + * A single-field section (VS-2): one card whose header row is the field row. + * The section title and description fill the label column, so the field's own + * label is not repeated; `SettingsRow` names the control "
". + */ +function SoleFieldSection({ + domId, + title, + description, + learnMoreUrl, + children, +}: t.SoleFieldSectionProps) { + const localize = useLocalize(); + const headingId = `${useId()}-heading`; + const context = useMemo(() => ({ headingId, sectionTitle: title }), [headingId, title]); -function renderCollapsible(isExpanded: boolean, hasEverExpanded: boolean, children: ReactNode) { - const shouldRender = isExpanded || hasEverExpanded; return ( -
-
-
{shouldRender && children}
+
+
+

+ {title} +

+ {description && ( +

+ {description} + {learnMoreUrl && ( + <> + {' '} + + {localize('com_ui_read_more')} + + + )} +

+ )} +
+ {children}
-
+
); } +/** A top-level configuration section: an h2 card that collapses, or a single-field card. */ export function ConfigSection({ sectionId, title, @@ -31,122 +64,104 @@ export function ConfigSection({ hidden, configuredCount = 0, totalCount = 0, + countsEntries, + overrideCount, defaultExpanded, inline, showConfiguredOnly, }: t.ConfigSectionProps) { - const localize = useLocalize(); const [isExpanded, setIsExpanded] = useState(() => defaultExpanded ?? configuredCount > 0); const [hasEverExpanded, setHasEverExpanded] = useState(isExpanded); const sectionRef = useRef(null); + const userToggledRef = useRef(false); useEffect(() => { if (isExpanded && !hasEverExpanded) setHasEverExpanded(true); }, [isExpanded, hasEverExpanded]); + useEffect(() => { + if (defaultExpanded === undefined && configuredCount > 0 && !userToggledRef.current) { + setIsExpanded(true); + } + }, [defaultExpanded, configuredCount]); + useEffect(() => { if (showConfiguredOnly && configuredCount > 0 && !isExpanded) setIsExpanded(true); }, [showConfiguredOnly]); - const toggle = useCallback(() => setIsExpanded((prev) => !prev), []); + /** While the tab filter is active only sections holding a match render, and they open. */ + const rowFilter = useContext(RowFilterContext); + useEffect(() => { + if (rowFilter) setIsExpanded(true); + }, [rowFilter]); + + const toggle = useCallback(() => { + userToggledRef.current = true; + setIsExpanded((prev) => !prev); + }, []); + + /** Expand all / Collapse all (WS5 `ExpandProvider`). */ + const applyExpandSignal = useCallback((expanded: boolean) => { + if (!expanded) userToggledRef.current = true; + setIsExpanded(expanded); + }, []); + useExpandSignal(applyExpandSignal); useEffect(() => { const el = sectionRef.current; if (!el) return; const expand = () => setIsExpanded(true); - const collapse = () => setIsExpanded(false); + const collapse = () => { + userToggledRef.current = true; + setIsExpanded(false); + }; el.addEventListener('config:expand', expand); el.addEventListener('config:collapse', collapse); return () => { el.removeEventListener('config:expand', expand); el.removeEventListener('config:collapse', collapse); }; - }, []); + }, [inline, hidden]); if (hidden) return null; - const hasConfigured = configuredCount > 0; + const domId = sectionId ? `section-${sectionId}` : undefined; if (inline) { return ( -
-
-
- {title} - {description && ( - {description} - )} -
-
{children}
-
-
-
+ {children} + ); } return ( -
+ } + expanded={isExpanded} + onToggle={toggle} + hasEverExpanded={hasEverExpanded} + sticky > - - {renderCollapsible(isExpanded, hasEverExpanded, children)} -
-
+ {children} + ); } diff --git a/src/components/configuration/ConfigSkeleton.tsx b/src/components/configuration/ConfigSkeleton.tsx new file mode 100644 index 00000000..5e617b2a --- /dev/null +++ b/src/components/configuration/ConfigSkeleton.tsx @@ -0,0 +1,53 @@ +import type * as t from '@/types'; +import { useLocalize } from '@/hooks'; +import { cn } from '@/utils'; + +const BAR = 'rounded bg-(--cui-color-background-secondary)'; +const ROW_WIDTHS = ['w-40', 'w-32', 'w-48', 'w-36', 'w-44', 'w-28']; + +/** Placeholder rows for the tab content while the baseline loads; never editable. */ +export function ConfigContentSkeleton({ rows = ROW_WIDTHS.length }: t.ConfigContentSkeletonProps) { + const localize = useLocalize(); + return ( +
+ {localize('com_config_shell_loading')} +
+
+ {ROW_WIDTHS.slice(0, rows).map((width, i) => ( +
+
+
+
+ ))} +
+
+
+
+ ); +} + +/** Route-level placeholder: tab bar, toolbar and rows (shown while the route loader runs). */ +export function ConfigSkeleton() { + return ( +
+