From c6bb77325ac869c8fbaa33ee8d2f7bf503b0fac1 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:40:44 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=97=20feat:=20Admin=20Panel=20Link=20i?= =?UTF-8?q?n=20Settings=20for=20Admins=20(#14662)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose ADMIN_PANEL_URL through the startup config for users holding the access:admin capability, and render an Admin section in Settings > General with an external link to the admin panel. The URL is omitted server-side for unauthenticated requests and users without admin access. --- .env.example | 1 + api/server/routes/__tests__/config.spec.js | 37 +++++++++++++++++++ api/server/routes/config.js | 10 +++-- .../Nav/Settings/__tests__/Sidebar.spec.tsx | 1 + .../Nav/Settings/__tests__/registry.spec.ts | 1 + .../src/components/Nav/Settings/context.tsx | 3 ++ .../src/components/Nav/Settings/registry.tsx | 11 ++++++ client/src/components/Nav/Settings/types.ts | 3 ++ .../Nav/SettingsTabs/General/AdminPanel.tsx | 26 +++++++++++++ client/src/locales/en/translation.json | 1 + packages/data-provider/src/config.ts | 2 + 11 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 client/src/components/Nav/SettingsTabs/General/AdminPanel.tsx diff --git a/.env.example b/.env.example index a38ee4d660..33b3133670 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,7 @@ DOMAIN_CLIENT=http://localhost:3080 DOMAIN_SERVER=http://localhost:3080 # External admin panel base URL used for admin OAuth/SSO redirects. +# When set, admins also get an Admin Panel link in Settings > General. # Required when the admin panel is hosted separately from LibreChat. # May include a path. Do not include a trailing slash. # Example: https://admin.example.com/admin diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 288d188d3d..ee53575574 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -103,6 +103,7 @@ afterEach(() => { delete process.env.SAML_CERT; delete process.env.SAML_SESSION_SECRET; delete process.env.ALLOW_ACCOUNT_DELETION; + delete process.env.ADMIN_PANEL_URL; delete process.env.ANALYTICS_GTM_ID; delete process.env.CUSTOM_FOOTER; delete process.env.HELP_AND_FAQ_URL; @@ -179,6 +180,7 @@ describe('GET /api/config', () => { process.env.ANALYTICS_GTM_ID = 'GTM-XYZ'; process.env.CUSTOM_FOOTER = 'internal footer text'; process.env.HELP_AND_FAQ_URL = 'https://internal.example.com/faq'; + process.env.ADMIN_PANEL_URL = 'https://admin.example.com'; mockGetAppConfig.mockResolvedValue(baseAppConfig); const app = createApp(null); @@ -193,6 +195,7 @@ describe('GET /api/config', () => { expect(response.body).not.toHaveProperty('openidReuseTokens'); expect(response.body).not.toHaveProperty('allowAccountDeletion'); expect(response.body).not.toHaveProperty('customFooter'); + expect(response.body).not.toHaveProperty('adminPanelURL'); }); it('should not include share-only fields when share context is requested', async () => { @@ -627,6 +630,40 @@ describe('GET /api/config', () => { expect(mockHasCapability).not.toHaveBeenCalled(); }); + it('should include adminPanelURL for users with ACCESS_ADMIN capability', async () => { + process.env.ADMIN_PANEL_URL = 'https://admin.example.com'; + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockHasCapability.mockResolvedValue(true); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.adminPanelURL).toBe('https://admin.example.com'); + expect(mockHasCapability).toHaveBeenCalled(); + }); + + it('should omit adminPanelURL for authenticated users without ACCESS_ADMIN', async () => { + process.env.ADMIN_PANEL_URL = 'https://admin.example.com'; + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockHasCapability.mockResolvedValue(false); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('adminPanelURL'); + expect(mockHasCapability).toHaveBeenCalled(); + }); + + it('should omit adminPanelURL when ADMIN_PANEL_URL is not set', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockHasCapability.mockResolvedValue(true); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('adminPanelURL'); + }); + it('should return 500 when getAppConfig throws', async () => { mockGetAppConfig.mockRejectedValue(new Error('Config service failure')); const app = createApp(mockUser); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index a93ebad342..6a14f5f760 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -319,15 +319,19 @@ router.get('/', async function (req, res) { payload.buildInfo = buildInfo; } - if (!payload.allowAccountDeletion) { + const adminPanelURL = process.env.ADMIN_PANEL_URL; + if (adminPanelURL || !payload.allowAccountDeletion) { try { const userId = req.user.id ?? req.user._id?.toString(); if (userId) { - const canDelete = await hasCapability( + const hasAdminAccess = await hasCapability( { id: userId, role: req.user.role ?? '', tenantId: req.user.tenantId }, SystemCapabilities.ACCESS_ADMIN, ); - if (canDelete) { + if (hasAdminAccess && adminPanelURL) { + payload.adminPanelURL = adminPanelURL; + } + if (hasAdminAccess && !payload.allowAccountDeletion) { payload.allowAccountDeletion = true; } } diff --git a/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx b/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx index 7cf5d0b307..02184e9d70 100644 --- a/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx +++ b/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx @@ -19,6 +19,7 @@ const ctx: SettingsContextValue = { aboutEnabled: false, engineTTS: 'browser', langfuseConnectionAccess: false, + adminPanelURL: '', }; function setup(extra: Partial = {}, query = '') { diff --git a/client/src/components/Nav/Settings/__tests__/registry.spec.ts b/client/src/components/Nav/Settings/__tests__/registry.spec.ts index c137b9cd48..bf254ee51a 100644 --- a/client/src/components/Nav/Settings/__tests__/registry.spec.ts +++ b/client/src/components/Nav/Settings/__tests__/registry.spec.ts @@ -21,6 +21,7 @@ const settingsContext: SettingsContextValue = { aboutEnabled: false, engineTTS: 'browser', langfuseConnectionAccess: false, + adminPanelURL: '', }; describe('settings registry', () => { diff --git a/client/src/components/Nav/Settings/context.tsx b/client/src/components/Nav/Settings/context.tsx index 176b8b584b..28a941e057 100644 --- a/client/src/components/Nav/Settings/context.tsx +++ b/client/src/components/Nav/Settings/context.tsx @@ -28,6 +28,7 @@ export function useSettingsContext(): SettingsContextValue { const balanceEnabled = startupConfig?.balance?.enabled === true; const langfuseConnectionAccess = startupConfig?.langfuseConnectionAccess === true; + const adminPanelURL = startupConfig?.adminPanelURL ?? ''; const isLocalProvider = user?.provider === 'local'; const twoFactorEnabled = user?.twoFactorEnabled === true; const allowAccountDeletion = startupConfig?.allowAccountDeletion !== false; @@ -53,6 +54,7 @@ export function useSettingsContext(): SettingsContextValue { aboutEnabled, engineTTS, langfuseConnectionAccess, + adminPanelURL, }), [ balanceEnabled, @@ -68,6 +70,7 @@ export function useSettingsContext(): SettingsContextValue { aboutEnabled, engineTTS, langfuseConnectionAccess, + adminPanelURL, ], ); } diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx index c572ef58ce..0d960c0fb6 100644 --- a/client/src/components/Nav/Settings/registry.tsx +++ b/client/src/components/Nav/Settings/registry.tsx @@ -33,6 +33,7 @@ import { DeleteCache } from '../SettingsTabs/Data/DeleteCache'; import { RevokeKeys } from '../SettingsTabs/Data/RevokeKeys'; import { ClearChats } from '../SettingsTabs/Data/ClearChats'; import { TokenCredits, AutoRefill } from './BillingControls'; +import AdminPanel from '../SettingsTabs/General/AdminPanel'; import SharedLinks from '../SettingsTabs/Data/SharedLinks'; import { showThinkingAtom } from '~/store/showThinking'; import ProviderKeys from '../SettingsTabs/ProviderKeys'; @@ -125,6 +126,16 @@ export const registry: SettingEntry[] = [ switchId: 'keepScreenAwake', }), }, + // General · Admin + { + id: 'adminPanel', + tab: GENERAL, + section: 'admin', + labelKey: 'com_ui_admin_panel', + keywords: ['admin', 'panel', 'dashboard'], + Component: AdminPanel, + show: (ctx) => ctx.adminPanelURL !== '', + }, // Chat · Sending { diff --git a/client/src/components/Nav/Settings/types.ts b/client/src/components/Nav/Settings/types.ts index e54dcedd07..64c01c9331 100644 --- a/client/src/components/Nav/Settings/types.ts +++ b/client/src/components/Nav/Settings/types.ts @@ -18,6 +18,7 @@ export type SectionId = | 'appearance' | 'layout' | 'accessibility' + | 'admin' | 'sending' | 'commands' | 'messages' @@ -49,6 +50,7 @@ export interface SettingsContextValue { aboutEnabled: boolean; engineTTS: string; langfuseConnectionAccess: boolean; + adminPanelURL: string; } export interface SettingEntry { @@ -96,6 +98,7 @@ export const TABS: TabMeta[] = [ { id: 'appearance', labelKey: 'com_ui_settings_section_appearance' }, { id: 'layout', labelKey: 'com_ui_settings_section_layout' }, { id: 'accessibility', labelKey: 'com_ui_settings_section_accessibility' }, + { id: 'admin', labelKey: 'com_ui_settings_section_admin' }, ], }, { diff --git a/client/src/components/Nav/SettingsTabs/General/AdminPanel.tsx b/client/src/components/Nav/SettingsTabs/General/AdminPanel.tsx new file mode 100644 index 0000000000..eb835c26d7 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/General/AdminPanel.tsx @@ -0,0 +1,26 @@ +import { ExternalLink } from 'lucide-react'; +import { Label, Button } from '@librechat/client'; +import { useGetStartupConfig } from '~/data-provider'; +import { useLocalize } from '~/hooks'; + +export default function AdminPanel() { + const localize = useLocalize(); + const { data: startupConfig } = useGetStartupConfig(); + const adminPanelURL = startupConfig?.adminPanelURL ?? ''; + + if (!adminPanelURL) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index dc3219d79a..ff5dc207b5 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1785,6 +1785,7 @@ "com_ui_settings_results_aria": "Search results", "com_ui_settings_search_placeholder": "Search settings", "com_ui_settings_section_accessibility": "Accessibility", + "com_ui_settings_section_admin": "Admin", "com_ui_settings_section_api_keys": "API keys", "com_ui_settings_section_appearance": "Appearance", "com_ui_settings_section_billing": "Billing", diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 73861605f3..5128f95abf 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1597,6 +1597,8 @@ export type TStartupConfig = { emailEnabled: boolean; showBirthdayIcon: boolean; helpAndFaqURL: string; + /** Admin panel link, only present for users with admin access */ + adminPanelURL?: string; customFooter?: string; modelSpecs?: TSpecsConfig; modelDescriptions?: Record>;