🔗 feat: Admin Panel Link in Settings for Admins (#14662)

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.
This commit is contained in:
Dustin Healy 2026-08-06 09:40:44 -07:00 committed by GitHub
parent 1367672942
commit c6bb77325a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 93 additions and 3 deletions

View file

@ -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

View file

@ -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);

View file

@ -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;
}
}

View file

@ -19,6 +19,7 @@ const ctx: SettingsContextValue = {
aboutEnabled: false,
engineTTS: 'browser',
langfuseConnectionAccess: false,
adminPanelURL: '',
};
function setup(extra: Partial<SettingsContextValue> = {}, query = '') {

View file

@ -21,6 +21,7 @@ const settingsContext: SettingsContextValue = {
aboutEnabled: false,
engineTTS: 'browser',
langfuseConnectionAccess: false,
adminPanelURL: '',
};
describe('settings registry', () => {

View file

@ -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,
],
);
}

View file

@ -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
{

View file

@ -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' },
],
},
{

View file

@ -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 (
<div className="flex items-center justify-between">
<Label id="admin-panel-label">{localize('com_ui_admin_panel')}</Label>
<Button asChild variant="outline" aria-labelledby="admin-panel-label">
<a href={adminPanelURL} target="_blank" rel="noopener noreferrer">
{localize('com_ui_open_var', { 0: localize('com_ui_admin_panel') })}
<ExternalLink className="size-4" aria-hidden="true" />
</a>
</Button>
</div>
);
}

View file

@ -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",

View file

@ -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<string, Record<string, string>>;