diff --git a/api/server/routes/config.js b/api/server/routes/config.js index ebafb05c30..583453fe4a 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -75,6 +75,7 @@ router.get('/', async function (req, res) { process.env.SHOW_BIRTHDAY_ICON === '', helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai', interface: req.app.locals.interfaceConfig, + turnstile: req.app.locals.turnstileConfig, modelSpecs: req.app.locals.modelSpecs, balance: req.app.locals.balance, sharedLinksEnabled, diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index 1ad3aaace6..5f119e67aa 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -12,6 +12,7 @@ const { initializeFirebase } = require('./Files/Firebase/initialize'); const loadCustomConfig = require('./Config/loadCustomConfig'); const handleRateLimits = require('./Config/handleRateLimits'); const { loadDefaultInterface } = require('./start/interface'); +const { loadTurnstileConfig } = require('./start/turnstile'); const { azureConfigSetup } = require('./start/azureOpenAI'); const { processModelSpecs } = require('./start/modelSpecs'); const { initializeS3 } = require('./Files/S3/initialize'); @@ -23,7 +24,6 @@ const { getMCPManager } = require('~/config'); const paths = require('~/config/paths'); /** - * * Loads custom config and initializes app-wide variables. * @function AppService * @param {Express.Application} app - The Express application object. @@ -74,6 +74,7 @@ const AppService = async (app) => { const socialLogins = config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins; const interfaceConfig = await loadDefaultInterface(config, configDefaults); + const turnstileConfig = loadTurnstileConfig(config, configDefaults); const defaultLocals = { ocr, @@ -85,6 +86,7 @@ const AppService = async (app) => { availableTools, imageOutputType, interfaceConfig, + turnstileConfig, balance, }; diff --git a/api/server/services/AppService.spec.js b/api/server/services/AppService.spec.js index 465ec9fdd6..81a017e41e 100644 --- a/api/server/services/AppService.spec.js +++ b/api/server/services/AppService.spec.js @@ -46,6 +46,12 @@ jest.mock('./ToolService', () => ({ }, }), })); +jest.mock('./start/turnstile', () => ({ + loadTurnstileConfig: jest.fn(() => ({ + siteKey: 'default-site-key', + options: {}, + })), +})); const azureGroups = [ { @@ -86,6 +92,10 @@ const azureGroups = [ describe('AppService', () => { let app; + const mockedTurnstileConfig = { + siteKey: 'default-site-key', + options: {}, + }; beforeEach(() => { app = { locals: {} }; @@ -107,6 +117,7 @@ describe('AppService', () => { sidePanel: true, presets: true, }), + turnstileConfig: mockedTurnstileConfig, modelSpecs: undefined, availableTools: { ExampleTool: { diff --git a/api/server/services/start/turnstile.js b/api/server/services/start/turnstile.js new file mode 100644 index 0000000000..ffd4545dae --- /dev/null +++ b/api/server/services/start/turnstile.js @@ -0,0 +1,35 @@ +const { removeNullishValues } = require('librechat-data-provider'); +const { logger } = require('~/config'); + +/** + * Loads and maps the Cloudflare Turnstile configuration. + * + * Expected config structure: + * + * turnstile: + * siteKey: "your-site-key-here" + * options: + * language: "auto" // "auto" or an ISO 639-1 language code (e.g. en) + * size: "normal" // Options: "normal", "compact", "flexible", or "invisible" + * + * @param {TCustomConfig | undefined} config - The loaded custom configuration. + * @param {TConfigDefaults} configDefaults - The custom configuration default values. + * @returns {TCustomConfig['turnstile']} The mapped Turnstile configuration. + */ +function loadTurnstileConfig(config, configDefaults) { + const { turnstile: customTurnstile = {} } = config ?? {}; + const { turnstile: defaults = {} } = configDefaults; + + /** @type {TCustomConfig['turnstile']} */ + const loadedTurnstile = removeNullishValues({ + siteKey: customTurnstile.siteKey ?? defaults.siteKey, + options: customTurnstile.options ?? defaults.options, + }); + + logger.info('Turnstile configuration loaded:\n' + JSON.stringify(loadedTurnstile, null, 2)); + return loadedTurnstile; +} + +module.exports = { + loadTurnstileConfig, +}; diff --git a/client/package.json b/client/package.json index 5fd9729a74..1b33c37919 100644 --- a/client/package.json +++ b/client/package.json @@ -34,6 +34,7 @@ "@dicebear/collection": "^9.2.2", "@dicebear/core": "^9.2.2", "@headlessui/react": "^2.1.2", + "@marsidev/react-turnstile": "^1.1.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.2", "@radix-ui/react-checkbox": "^1.0.3", diff --git a/client/src/components/Auth/LoginForm.tsx b/client/src/components/Auth/LoginForm.tsx index 2cd62d08b9..030b6323f7 100644 --- a/client/src/components/Auth/LoginForm.tsx +++ b/client/src/components/Auth/LoginForm.tsx @@ -1,9 +1,10 @@ import { useForm } from 'react-hook-form'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useContext } from 'react'; +import { Turnstile } from '@marsidev/react-turnstile'; import type { TLoginUser, TStartupConfig } from 'librechat-data-provider'; import type { TAuthContext } from '~/common'; import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider'; -import { useLocalize } from '~/hooks'; +import { ThemeContext, useLocalize } from '~/hooks'; type TLoginFormProps = { onSubmit: (data: TLoginUser) => void; @@ -14,6 +15,7 @@ type TLoginFormProps = { const LoginForm: React.FC = ({ onSubmit, startupConfig, error, setError }) => { const localize = useLocalize(); + const { theme } = useContext(ThemeContext); const { register, getValues, @@ -21,9 +23,11 @@ const LoginForm: React.FC = ({ onSubmit, startupConfig, error, formState: { errors }, } = useForm(); const [showResendLink, setShowResendLink] = useState(false); + const [turnstileToken, setTurnstileToken] = useState(null); const { data: config } = useGetStartupConfig(); const useUsernameLogin = config?.ldap?.username; + const validTheme = theme === 'dark' ? 'dark' : 'light'; useEffect(() => { if (error && error.includes('422') && !showResendLink) { @@ -159,11 +163,29 @@ const LoginForm: React.FC = ({ onSubmit, startupConfig, error, {localize('com_auth_password_forgot')} )} + + {/* Render Turnstile only if enabled in startupConfig */} + {startupConfig.turnstile && ( +
+ setTurnstileToken(token)} + onError={() => setTurnstileToken(null)} + onExpire={() => setTurnstileToken(null)} + /> +
+ )} +
+ )} +
diff --git a/client/src/components/SidePanel/Agents/AgentFooter.tsx b/client/src/components/SidePanel/Agents/AgentFooter.tsx index 062f81d253..75f10a3851 100644 --- a/client/src/components/SidePanel/Agents/AgentFooter.tsx +++ b/client/src/components/SidePanel/Agents/AgentFooter.tsx @@ -50,10 +50,12 @@ export default function AgentFooter({ return localize('com_ui_create'); }; + const showButtons = activePanel === Panel.builder; + return (
- {activePanel !== Panel.advanced && } - {user?.role === SystemRoles.ADMIN && } + {showButtons && } + {user?.role === SystemRoles.ADMIN && showButtons && } {/* Context Button */}
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN) && hasAccessToShareAgents && ( - - )} + + )} {agent && agent.author === user?.id && } {/* Submit Button */}