Skip to content

dev #7399

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 15, 2025
Merged

dev #7399

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/server/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion api/server/services/AppService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -85,6 +86,7 @@ const AppService = async (app) => {
availableTools,
imageOutputType,
interfaceConfig,
turnstileConfig,
balance,
};

Expand Down
11 changes: 11 additions & 0 deletions api/server/services/AppService.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ jest.mock('./ToolService', () => ({
},
}),
}));
jest.mock('./start/turnstile', () => ({
loadTurnstileConfig: jest.fn(() => ({
siteKey: 'default-site-key',
options: {},
})),
}));

const azureGroups = [
{
Expand Down Expand Up @@ -86,6 +92,10 @@ const azureGroups = [

describe('AppService', () => {
let app;
const mockedTurnstileConfig = {
siteKey: 'default-site-key',
options: {},
};

beforeEach(() => {
app = { locals: {} };
Expand All @@ -107,6 +117,7 @@ describe('AppService', () => {
sidePanel: true,
presets: true,
}),
turnstileConfig: mockedTurnstileConfig,
modelSpecs: undefined,
availableTools: {
ExampleTool: {
Expand Down
35 changes: 35 additions & 0 deletions api/server/services/start/turnstile.js
Original file line number Diff line number Diff line change
@@ -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,
};
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 24 additions & 2 deletions client/src/components/Auth/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,16 +15,19 @@ type TLoginFormProps = {

const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error, setError }) => {
const localize = useLocalize();
const { theme } = useContext(ThemeContext);
const {
register,
getValues,
handleSubmit,
formState: { errors },
} = useForm<TLoginUser>();
const [showResendLink, setShowResendLink] = useState<boolean>(false);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);

const { data: config } = useGetStartupConfig();
const useUsernameLogin = config?.ldap?.username;
const validTheme = theme === 'dark' ? 'dark' : 'light';

useEffect(() => {
if (error && error.includes('422') && !showResendLink) {
Expand Down Expand Up @@ -159,11 +163,29 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
{localize('com_auth_password_forgot')}
</a>
)}

{/* Render Turnstile only if enabled in startupConfig */}
{startupConfig.turnstile && (
<div className="my-4 flex justify-center">
<Turnstile
siteKey={startupConfig.turnstile.siteKey}
options={{
...startupConfig.turnstile.options,
theme: validTheme,
}}
onSuccess={(token) => setTurnstileToken(token)}
onError={() => setTurnstileToken(null)}
onExpire={() => setTurnstileToken(null)}
/>
</div>
)}

<div className="mt-6">
<button
aria-label={localize('com_auth_continue')}
data-testid="login-button"
type="submit"
disabled={startupConfig.turnstile ? !turnstileToken : false}
className="
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700
Expand Down
41 changes: 33 additions & 8 deletions client/src/components/Auth/Registration.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { useForm } from 'react-hook-form';
import React, { useState } from 'react';
import React, { useContext, useState } from 'react';
import { Turnstile } from '@marsidev/react-turnstile';
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
import type { TRegisterUser, TError } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import { ErrorMessage } from './ErrorMessage';
import { Spinner } from '~/components/svg';
import { useLocalize, TranslationKeys } from '~/hooks';
import { useLocalize, TranslationKeys, ThemeContext } from '~/hooks';

const Registration: React.FC = () => {
const navigate = useNavigate();
const localize = useLocalize();
const { theme } = useContext(ThemeContext);
const { startupConfig, startupConfigError, isFetching } = useOutletContext<TLoginLayoutContext>();

const {
Expand All @@ -24,10 +26,12 @@ const Registration: React.FC = () => {
const [errorMessage, setErrorMessage] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [countdown, setCountdown] = useState<number>(3);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);

const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const token = queryParams.get('token');
const validTheme = theme === 'dark' ? 'dark' : 'light';

const registerUser = useRegisterUserMutation({
onMutate: () => {
Expand Down Expand Up @@ -178,17 +182,38 @@ const Registration: React.FC = () => {
validate: (value: string) =>
value === password || localize('com_auth_password_not_match'),
})}

{/* Render Turnstile only if enabled in startupConfig */}
{startupConfig?.turnstile && (
<div className="my-4 flex justify-center">
<Turnstile
siteKey={startupConfig.turnstile.siteKey}
options={{
...startupConfig.turnstile.options,
theme: validTheme,
}}
onSuccess={(token) => setTurnstileToken(token)}
onError={() => setTurnstileToken(null)}
onExpire={() => setTurnstileToken(null)}
/>
</div>
)}

<div className="mt-6">
<button
disabled={Object.keys(errors).length > 0}
disabled={
Object.keys(errors).length > 0 ||
isSubmitting ||
(startupConfig?.turnstile ? !turnstileToken : false)
}
type="submit"
aria-label="Submit registration"
className="
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
"
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
"
>
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
</button>
Expand Down
20 changes: 11 additions & 9 deletions client/src/components/SidePanel/Agents/AgentFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,12 @@ export default function AgentFooter({
return localize('com_ui_create');
};

const showButtons = activePanel === Panel.builder;

return (
<div className="mx-1 mb-1 flex w-full flex-col gap-2">
{activePanel !== Panel.advanced && <AdvancedButton setActivePanel={setActivePanel} />}
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
{showButtons && <AdvancedButton setActivePanel={setActivePanel} />}
{user?.role === SystemRoles.ADMIN && showButtons && <AdminSettings />}
{/* Context Button */}
<div className="flex items-center justify-end gap-2">
<DeleteButton
Expand All @@ -63,13 +65,13 @@ export default function AgentFooter({
/>
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN) &&
hasAccessToShareAgents && (
<ShareAgent
agent_id={agent_id}
agentName={agent?.name ?? ''}
projectIds={agent?.projectIds ?? []}
isCollaborative={agent?.isCollaborative}
/>
)}
<ShareAgent
agent_id={agent_id}
agentName={agent?.name ?? ''}
projectIds={agent?.projectIds ?? []}
isCollaborative={agent?.isCollaborative}
/>
)}
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
{/* Submit Button */}
<button
Expand Down
1 change: 1 addition & 0 deletions client/src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import './style.css';
import './mobile.css';
import { ApiErrorBoundaryProvider } from './hooks/ApiErrorBoundaryContext';
import 'katex/dist/katex.min.css';
import 'katex/dist/contrib/copy-tex.js';

const container = document.getElementById('root');
const root = createRoot(container);
Expand Down
7 changes: 7 additions & 0 deletions librechat.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ interface:
multiConvo: true
agents: true

# Example Cloudflare turnstile (optional)
#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"

# Example Registration Object Structure (optional)
registration:
socialLogins: ['github', 'google', 'discord', 'openid', 'facebook', 'apple']
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions packages/data-provider/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,10 +505,28 @@ export const intefaceSchema = z
export type TInterfaceConfig = z.infer<typeof intefaceSchema>;
export type TBalanceConfig = z.infer<typeof balanceSchema>;

export const turnstileOptionsSchema = z
.object({
language: z.string().default('auto'),
size: z.enum(['normal', 'compact', 'flexible', 'invisible']).default('normal'),
})
.default({
language: 'auto',
size: 'normal',
});

export const turnstileSchema = z.object({
siteKey: z.string(),
options: turnstileOptionsSchema.optional(),
});

export type TTurnstileConfig = z.infer<typeof turnstileSchema>;

export type TStartupConfig = {
appTitle: string;
socialLogins?: string[];
interface?: TInterfaceConfig;
turnstile?: TTurnstileConfig;
balance?: TBalanceConfig;
discordLoginEnabled: boolean;
facebookLoginEnabled: boolean;
Expand Down Expand Up @@ -578,6 +596,7 @@ export const configSchema = z.object({
filteredTools: z.array(z.string()).optional(),
mcpServers: MCPServersSchema.optional(),
interface: intefaceSchema,
turnstile: turnstileSchema.optional(),
fileStrategy: fileSourceSchema.default(FileSources.local),
actions: z
.object({
Expand Down