🚫 fix: Hide Delete Account Button When ALLOW_ACCOUNT_DELETION Is Disabled (#12568)

* fix: hide Delete Account button when ALLOW_ACCOUNT_DELETION is false

* fix: add admin bypass, inline env read, and tests for allowAccountDeletion

- Show delete button for admin users even when ALLOW_ACCOUNT_DELETION=false,
  matching the canDeleteAccount middleware's ACCESS_ADMIN bypass
- Move env var read inline in buildSharedPayload() for per-request evaluation
- Add 4 frontend tests for Account conditional rendering
- Add 3 backend tests for allowAccountDeletion config field

* fix: use server-side ACCESS_ADMIN capability check instead of frontend role check

- Replace frontend SystemRoles.ADMIN check with server-side hasCapability()
  in the authenticated config route, matching canDeleteAccount middleware exactly
- Admin bypass now evaluates ACCESS_ADMIN capability per-user in GET /api/config,
  so users with the grant (regardless of role) see the button, and admins
  without the grant do not
- Add 3 authenticated backend tests: without capability, with capability,
  and skip-when-already-enabled
- Simplify frontend to pure config check (no role logic)
- Remove redundant jest-dom import; add inline env var comment

* test: add missing toHaveBeenCalled assertion in ACCESS_ADMIN test
This commit is contained in:
Danny Avila 2026-04-07 23:51:23 -04:00 committed by GitHub
parent 223065c411
commit d350c58633
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 164 additions and 4 deletions

View file

@ -9,6 +9,11 @@ jest.mock('~/server/services/Config/ldap', () => ({
getLdapConfig: jest.fn(() => null),
}));
const mockHasCapability = jest.fn();
jest.mock('~/server/middleware/roles/capabilities', () => ({
hasCapability: (...args) => mockHasCapability(...args),
}));
const mockGetTenantId = jest.fn(() => undefined);
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
@ -76,6 +81,7 @@ afterEach(() => {
delete process.env.SAML_ISSUER;
delete process.env.SAML_CERT;
delete process.env.SAML_SESSION_SECRET;
delete process.env.ALLOW_ACCOUNT_DELETION;
});
describe('GET /api/config', () => {
@ -181,6 +187,35 @@ describe('GET /api/config', () => {
expect(response.body).toHaveProperty('serverDomain');
});
it('should default allowAccountDeletion to true when env var is unset', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
const app = createApp(null);
const response = await request(app).get('/api/config');
expect(response.body.allowAccountDeletion).toBe(true);
});
it('should set allowAccountDeletion to false when ALLOW_ACCOUNT_DELETION=false', async () => {
process.env.ALLOW_ACCOUNT_DELETION = 'false';
mockGetAppConfig.mockResolvedValue(baseAppConfig);
const app = createApp(null);
const response = await request(app).get('/api/config');
expect(response.body.allowAccountDeletion).toBe(false);
});
it('should set allowAccountDeletion to true when ALLOW_ACCOUNT_DELETION=true', async () => {
process.env.ALLOW_ACCOUNT_DELETION = 'true';
mockGetAppConfig.mockResolvedValue(baseAppConfig);
const app = createApp(null);
const response = await request(app).get('/api/config');
expect(response.body.allowAccountDeletion).toBe(true);
});
it('should return 500 when getAppConfig throws', async () => {
mockGetAppConfig.mockRejectedValue(new Error('Config service failure'));
const app = createApp(null);
@ -277,6 +312,40 @@ describe('GET /api/config', () => {
);
});
it('should set allowAccountDeletion to false for authenticated users without ACCESS_ADMIN', async () => {
process.env.ALLOW_ACCOUNT_DELETION = 'false';
mockGetAppConfig.mockResolvedValue(baseAppConfig);
mockHasCapability.mockResolvedValue(false);
const app = createApp(mockUser);
const response = await request(app).get('/api/config');
expect(response.body.allowAccountDeletion).toBe(false);
expect(mockHasCapability).toHaveBeenCalled();
});
it('should override allowAccountDeletion to true for users with ACCESS_ADMIN capability', async () => {
process.env.ALLOW_ACCOUNT_DELETION = 'false';
mockGetAppConfig.mockResolvedValue(baseAppConfig);
mockHasCapability.mockResolvedValue(true);
const app = createApp(mockUser);
const response = await request(app).get('/api/config');
expect(response.body.allowAccountDeletion).toBe(true);
expect(mockHasCapability).toHaveBeenCalled();
});
it('should not call hasCapability when allowAccountDeletion is already true', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
const app = createApp(mockUser);
const response = await request(app).get('/api/config');
expect(response.body.allowAccountDeletion).toBe(true);
expect(mockHasCapability).not.toHaveBeenCalled();
});
it('should return 500 when getAppConfig throws', async () => {
mockGetAppConfig.mockRejectedValue(new Error('Config service failure'));
const app = createApp(mockUser);

View file

@ -1,7 +1,8 @@
const express = require('express');
const { isEnabled, getBalanceConfig } = require('@librechat/api');
const { defaultSocialLogins } = require('librechat-data-provider');
const { logger, getTenantId } = require('@librechat/data-schemas');
const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getAppConfig } = require('~/server/services/Config/app');
@ -77,6 +78,10 @@ function buildSharedPayload() {
publicSharedLinksEnabled,
analyticsGtmId: process.env.ANALYTICS_GTM_ID,
openidReuseTokens,
/** Read inline (not module-level) for per-request evaluation and test isolation */
allowAccountDeletion:
process.env.ALLOW_ACCOUNT_DELETION === undefined ||
isEnabled(process.env.ALLOW_ACCOUNT_DELETION),
};
const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
@ -172,6 +177,23 @@ router.get('/', async function (req, res) {
payload.webSearch = webSearch;
}
if (!payload.allowAccountDeletion) {
try {
const userId = req.user.id ?? req.user._id?.toString();
if (userId) {
const canDelete = await hasCapability(
{ id: userId, role: req.user.role ?? '', tenantId: req.user.tenantId },
SystemCapabilities.ACCESS_ADMIN,
);
if (canDelete) {
payload.allowAccountDeletion = true;
}
}
} catch (err) {
logger.warn(`[config] ACCESS_ADMIN capability check failed: ${err.message}`);
}
}
return res.status(200).send(payload);
} catch (err) {
logger.error('Error in startup config', err);