mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🔗 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:
parent
1367672942
commit
c6bb77325a
11 changed files with 93 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const ctx: SettingsContextValue = {
|
|||
aboutEnabled: false,
|
||||
engineTTS: 'browser',
|
||||
langfuseConnectionAccess: false,
|
||||
adminPanelURL: '',
|
||||
};
|
||||
|
||||
function setup(extra: Partial<SettingsContextValue> = {}, query = '') {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const settingsContext: SettingsContextValue = {
|
|||
aboutEnabled: false,
|
||||
engineTTS: 'browser',
|
||||
langfuseConnectionAccess: false,
|
||||
adminPanelURL: '',
|
||||
};
|
||||
|
||||
describe('settings registry', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue