From 7862cc73b9481585994bb697c62be5cda060a988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Airam=20Hern=C3=A1ndez=20Hern=C3=A1ndez?= Date: Wed, 20 May 2026 12:49:36 +0000 Subject: [PATCH 1/4] feat: add configurable subpath routing support Add support for deploying the admin panel under a configurable URL subpath (e.g., /adminpanel) via the VITE_BASE_PATH environment variable. The app defaults to serving from root (/) when unset, preserving backward compatibility. Changes: - vite.config.ts: set Vite 'base' from VITE_BASE_PATH for correct asset prefixing - src/router.tsx: configure TanStack Router basepath from VITE_BASE_PATH - src/routes/__root.tsx: prefix favicon href with base path - server.ts: prefix static file routes with BASE_PATH, add /health endpoint, add redirect from BASE_PATH (no slash) to BASE_PATH/ - Dockerfile: accept VITE_BASE_PATH as build ARG, fix healthcheck to use /health - src/server/session.ts: scope session cookie path to VITE_BASE_PATH to prevent cookie conflicts with other apps on the same domain - .env.example: document VITE_BASE_PATH variable --- .env.example | 4 ++++ Dockerfile | 4 +++- README.md | 10 ++++++++++ server.ts | 7 +++++-- src/router.tsx | 1 + src/routes/__root.tsx | 2 +- src/server/session.ts | 3 +++ vite.config.ts | 1 + 8 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index ca58549..d9e8824 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,10 @@ SESSION_SECRET= # The endpoint returns 401 if this is unset or the token does not match. # ADMIN_PANEL_METRICS_SECRET= +# URL base path for serving the admin panel under a subpath (e.g., /adminpanel). +# Must match at build time and runtime. Leave unset for root (/). +# VITE_BASE_PATH=/adminpanel + # Browser-facing URL of the LibreChat API server (used for OAuth redirects). # Defaults to http://localhost:3080 # VITE_API_BASE_URL=http://localhost:3080 diff --git a/Dockerfile b/Dockerfile index 0c6cc62..7dc6cc4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,8 @@ RUN bun install --frozen-lockfile FROM base AS build COPY --from=deps /app/node_modules node_modules COPY . . +ARG VITE_BASE_PATH=/ +ENV VITE_BASE_PATH=${VITE_BASE_PATH} ENV NODE_ENV=production RUN bun run build @@ -39,7 +41,7 @@ USER bun ENV PORT=3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD bun -e "fetch(\`http://localhost:\${process.env.PORT}\`).then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))" + CMD bun -e "fetch(\`http://localhost:\${process.env.PORT}/health\`).then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))" EXPOSE 3000 CMD ["bun", "run", "start"] diff --git a/README.md b/README.md index df3bf99..74584b6 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ docker compose down # stop | `PORT` | No | `3000` | Port the admin panel listens on | | `SESSION_SECRET` | **Yes** (always required in Docker) | Dev fallback only when running `bun dev` locally; no default in the Docker image | Encryption key for sessions (min 32 chars) | | `VITE_API_BASE_URL` | **Yes** (Docker) | `http://localhost:3080` (local dev only) | LibreChat API server URL; use `http://host.docker.internal:` in Docker | +| `VITE_BASE_PATH` | No | `/` | URL subpath to serve the panel under (e.g., `/adminpanel`). Must match at build time and runtime | | `API_SERVER_URL` | No | Falls back to `VITE_API_BASE_URL` | Server-side LibreChat API URL when the container reaches LibreChat differently than the browser | | `ADMIN_SSO_ONLY` | No | `false` | Hide email/password form, SSO only | | `ADMIN_SESSION_IDLE_TIMEOUT_MS` | No | `1800000` (30 min) | Session idle timeout in ms | @@ -66,4 +67,13 @@ docker run -p 3000:3000 \ -e VITE_API_BASE_URL=http://host.docker.internal:3080 \ -e SESSION_COOKIE_SECURE=false \ librechat-admin-panel + +# To serve under a subpath (e.g., /adminpanel): +docker build -t librechat-admin-panel --build-arg VITE_BASE_PATH=/adminpanel . +docker run -p 3000:3000 \ + --add-host=host.docker.internal:host-gateway \ + -e SESSION_SECRET=your-secret-here-at-least-32-characters \ + -e VITE_API_BASE_URL=http://host.docker.internal:3080 \ + -e VITE_BASE_PATH=/adminpanel \ + librechat-admin-panel ``` diff --git a/server.ts b/server.ts index 70ef218..634257d 100644 --- a/server.ts +++ b/server.ts @@ -11,6 +11,7 @@ const CLIENT_DIR = join(import.meta.dir, 'dist', 'client'); const SERVER_ENTRY = new URL('./dist/server/server.js', import.meta.url); const env = process.env; +const BASE_PATH = (env.VITE_BASE_PATH || '').replace(/\/$/, ''); const ONE_DAY = 86400; const rawMaxAge = Number(env.ADMIN_PANEL_STATIC_CACHE_MAX_AGE ?? env.STATIC_CACHE_MAX_AGE); @@ -63,7 +64,7 @@ async function buildStaticRoutes(): Promise Pro for await (const path of new Glob('**/*').scan(CLIENT_DIR)) { const file = Bun.file(`${CLIENT_DIR}/${path}`); const cache = getCacheHeaders(path); - const routePath = `/${path}`; + const routePath = `${BASE_PATH}/${path}`; routes[routePath] = (req) => withHttpMetrics( req, @@ -79,6 +80,8 @@ const server = Bun.serve({ routes: { ...(await buildStaticRoutes()), '/metrics': (req) => metricsResponse(req), + '/health': () => new Response('ok'), + ...(BASE_PATH ? { [`${BASE_PATH}`]: () => Response.redirect(`${BASE_PATH}/`, 302) } : {}), '/*': async (req) => { const url = new URL(req.url); const res = await withHttpMetrics(req, url.pathname, () => handler.fetch(req)); @@ -91,7 +94,7 @@ const server = Bun.serve({ }, }); -console.log(`Admin panel listening on http://localhost:${server.port}`); +console.log(`Admin panel listening on http://localhost:${server.port}${BASE_PATH}/`); if (!process.env.ADMIN_PANEL_METRICS_SECRET) { console.warn( diff --git a/src/router.tsx b/src/router.tsx index 03de35b..8f38e33 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -7,6 +7,7 @@ export function getRouter() { const queryClient = new QueryClient(); const router = createTanStackRouter({ routeTree, + basepath: import.meta.env.VITE_BASE_PATH || '/', context: { queryClient }, scrollRestoration: true, defaultPreload: 'intent', diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index fcc82e7..f96d4ad 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -46,7 +46,7 @@ export const Route = createRootRoute({ }, { rel: 'icon', - href: '/favicon.ico', + href: `${import.meta.env.VITE_BASE_PATH || ''}/favicon.ico`, }, ], }), diff --git a/src/server/session.ts b/src/server/session.ts index 28f7546..38a00a7 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -40,11 +40,14 @@ if (!process.env.SESSION_SECRET && process.env.NODE_ENV === 'development') { ); } +const sessionCookiePath = process.env.VITE_BASE_PATH || '/'; + export function useAppSession(): ReturnType> { return useSession({ name: 'admin-session', password: sessionSecret || '', cookie: { + path: sessionCookiePath, secure: sessionCookieSecure, sameSite: 'lax', httpOnly: true, diff --git a/vite.config.ts b/vite.config.ts index 832ae92..0585c77 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,7 @@ import viteReact from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' const config = defineConfig({ + base: process.env.VITE_BASE_PATH || '/', plugins: [ devtools(), tailwindcss(), From 94532c4c7157c51d11c05b0055366f4aed2caa39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Airam=20Hern=C3=A1ndez=20Hern=C3=A1ndez?= Date: Thu, 21 May 2026 12:46:43 +0000 Subject: [PATCH 2/4] fix: remove redirect_uri from SSO initiation to prevent passport 401 openid-client v6 uses currentUrl.searchParams.size === 0 to distinguish initiation from callback. The redirect_uri param was not stripped by LibreChat's storeAndStripChallenge (only code_challenge is), causing Passport to misclassify the request as a callback and return 401. LibreChat already knows the admin panel callback URL via ADMIN_PANEL_URL env var, so the redirect_uri param was unnecessary. --- src/server/auth.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/server/auth.ts b/src/server/auth.ts index 240d400..9a1bf77 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -347,13 +347,10 @@ export const openidLoginFn = createServerFn({ method: 'GET' }).handler(async () try { const baseUrl = getApiBaseUrl(); const authUrl = new URL(`${baseUrl}/api/admin/oauth/openid`); - const requestOrigin = getRequestOrigin(); const codeVerifier = crypto.randomBytes(32).toString('hex'); const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('hex'); authUrl.searchParams.set('code_challenge', codeChallenge); - if (requestOrigin) - authUrl.searchParams.set('redirect_uri', `${requestOrigin}/auth/openid/callback`); const session = await useAppSession(); await session.update({ codeVerifier }); From 64694cd5dcadf2393806a34b277e2115614dd4f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Airam=20Hern=C3=A1ndez=20Hern=C3=A1ndez?= Date: Thu, 21 May 2026 13:05:45 +0000 Subject: [PATCH 3/4] fix: use Link for back-to-login to respect basepath --- src/routes/auth/openid/callback.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/routes/auth/openid/callback.tsx b/src/routes/auth/openid/callback.tsx index 09153ae..2e2a0af 100644 --- a/src/routes/auth/openid/callback.tsx +++ b/src/routes/auth/openid/callback.tsx @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { createFileRoute, redirect } from '@tanstack/react-router'; +import { createFileRoute, redirect, Link } from '@tanstack/react-router'; import { oauthExchangeFn } from '@/server'; import { useLocalize } from '@/hooks'; @@ -62,12 +62,12 @@ function OpenIdCallback() { {localize('com_auth_sso_error_title')}

{errorMessage}

- {localize('com_auth_sso_back_to_login')} - + ); From 143d97663209a77b3fb93387687acd857e61e1ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Airam=20Hern=C3=A1ndez=20Hern=C3=A1ndez?= Date: Sat, 23 May 2026 18:13:31 +0000 Subject: [PATCH 4/4] fix: address codex review comments - Normalize VITE_BASE_PATH before building favicon URL to avoid //favicon.ico - Strip BASE_PATH prefix from pathname before recording route metrics - Add required search prop to Link component to fix TS2741 --- server.ts | 5 ++++- src/routes/__root.tsx | 2 +- src/routes/auth/openid/callback.tsx | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/server.ts b/server.ts index 634257d..d5457a5 100644 --- a/server.ts +++ b/server.ts @@ -84,7 +84,10 @@ const server = Bun.serve({ ...(BASE_PATH ? { [`${BASE_PATH}`]: () => Response.redirect(`${BASE_PATH}/`, 302) } : {}), '/*': async (req) => { const url = new URL(req.url); - const res = await withHttpMetrics(req, url.pathname, () => handler.fetch(req)); + const metricsPath = BASE_PATH && url.pathname.startsWith(BASE_PATH) + ? url.pathname.slice(BASE_PATH.length) || '/' + : url.pathname; + const res = await withHttpMetrics(req, metricsPath, () => handler.fetch(req)); const patched = new Response(res.body, res); for (const [k, v] of Object.entries(NO_CACHE)) { patched.headers.set(k, v); diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index f96d4ad..18d99af 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -46,7 +46,7 @@ export const Route = createRootRoute({ }, { rel: 'icon', - href: `${import.meta.env.VITE_BASE_PATH || ''}/favicon.ico`, + href: `${(import.meta.env.VITE_BASE_PATH || '').replace(/\/$/, '')}/favicon.ico`, }, ], }), diff --git a/src/routes/auth/openid/callback.tsx b/src/routes/auth/openid/callback.tsx index 2e2a0af..4472304 100644 --- a/src/routes/auth/openid/callback.tsx +++ b/src/routes/auth/openid/callback.tsx @@ -64,6 +64,7 @@ function OpenIdCallback() {

{errorMessage}

{localize('com_auth_sso_back_to_login')}