🔗 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

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