From a2c35e841589f6cede194795202cb6559e85fa7a Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+Berry-13@users.noreply.github.com> Date: Mon, 5 Feb 2024 09:31:18 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=84=F0=9F=94=90=20refactor:=20auth;=20?= =?UTF-8?q?style:=20match=20OpenAI;=20feat:=20custom=20social=20login=20or?= =?UTF-8?q?der=20(#1421)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(Login & Registration) * fix(Registration) test errors * refactor(LoginForm & ResetPassword) * fix(LoginForm): display 'undefined' when loading page; style(SocialButton): match OpenAI's graphics * some refactor and style update for social logins * style: width like OpenAI; feat: custom social login order; refactor: alphabetical socials * fix(Registration & Login) test * Update .env.example * Update .env.example * Update dotenv.md * refactor: remove `SOCIAL_LOGIN_ORDER` for `socialLogins` configured from `librechat.yaml` - initialized by AppService, attached as app.locals property - rename socialLoginOrder and loginOrder to socialLogins app-wide for consistency - update types and docs - initialize config variable as array and not singular string to parse - bump data-provider to 0.3.9 --------- Co-authored-by: Danny Avila --- api/server/routes/__tests__/config.spec.js | 7 +- api/server/routes/config.js | 9 +- api/server/services/AppService.js | 9 + .../services/Config/loadCustomConfig.js | 2 +- client/src/components/Auth/Login.tsx | 169 ++++--- client/src/components/Auth/LoginForm.tsx | 68 +-- client/src/components/Auth/Registration.tsx | 474 +++++++----------- .../components/Auth/RequestPasswordReset.tsx | 145 +++--- client/src/components/Auth/ResetPassword.tsx | 9 +- client/src/components/Auth/SocialButton.tsx | 78 +++ .../components/Auth/__tests__/Login.spec.tsx | 23 +- .../Auth/__tests__/Registration.spec.tsx | 24 +- client/src/localization/languages/Eng.tsx | 9 +- client/src/localization/languages/It.tsx | 8 +- client/tailwind.config.cjs | 4 + docs/install/configuration/custom_config.md | 16 + docs/install/configuration/dotenv.md | 1 - librechat.example.yaml | 3 +- packages/data-provider/package.json | 2 +- packages/data-provider/src/config.ts | 1 + packages/data-provider/src/types.ts | 7 +- 21 files changed, 536 insertions(+), 532 deletions(-) create mode 100644 client/src/components/Auth/SocialButton.tsx diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 4032d23b5..bc3742dff 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -56,13 +56,14 @@ describe.skip('GET /', () => { expect(response.statusCode).toBe(200); expect(response.body).toEqual({ appTitle: 'Test Title', - googleLoginEnabled: true, + socialLogins: ['google', 'facebook', 'openid', 'github', 'discord'], + discordLoginEnabled: true, facebookLoginEnabled: true, + githubLoginEnabled: true, + googleLoginEnabled: true, openidLoginEnabled: true, openidLabel: 'Test OpenID', openidImageUrl: 'http://test-server.com', - githubLoginEnabled: true, - discordLoginEnabled: true, serverDomain: 'http://test-server.com', emailLoginEnabled: 'true', registrationEnabled: 'true', diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 85889f4b8..02d3fc2c3 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -10,18 +10,19 @@ router.get('/', async function (req, res) { try { const payload = { appTitle: process.env.APP_TITLE || 'LibreChat', - googleLoginEnabled: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET, + socialLogins: req.app.locals.socialLogins, + discordLoginEnabled: !!process.env.DISCORD_CLIENT_ID && !!process.env.DISCORD_CLIENT_SECRET, facebookLoginEnabled: !!process.env.FACEBOOK_CLIENT_ID && !!process.env.FACEBOOK_CLIENT_SECRET, + githubLoginEnabled: !!process.env.GITHUB_CLIENT_ID && !!process.env.GITHUB_CLIENT_SECRET, + googleLoginEnabled: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET, openidLoginEnabled: !!process.env.OPENID_CLIENT_ID && !!process.env.OPENID_CLIENT_SECRET && !!process.env.OPENID_ISSUER && !!process.env.OPENID_SESSION_SECRET, - openidLabel: process.env.OPENID_BUTTON_LABEL || 'Login with OpenID', + openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID', openidImageUrl: process.env.OPENID_IMAGE_URL, - githubLoginEnabled: !!process.env.GITHUB_CLIENT_ID && !!process.env.GITHUB_CLIENT_SECRET, - discordLoginEnabled: !!process.env.DISCORD_CLIENT_ID && !!process.env.DISCORD_CLIENT_SECRET, serverDomain: process.env.DOMAIN_SERVER || 'http://localhost:3080', emailLoginEnabled, registrationEnabled: isEnabled(process.env.ALLOW_REGISTRATION), diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index b1f7cf57d..63c136704 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -10,7 +10,15 @@ const paths = require('~/config/paths'); * @param {Express.Application} app - The Express application object. */ const AppService = async (app) => { + /** @type {TCustomConfig}*/ const config = (await loadCustomConfig()) ?? {}; + const socialLogins = config.registration.socialLogins ?? [ + 'google', + 'facebook', + 'openid', + 'github', + 'discord', + ]; const fileStrategy = config.fileStrategy ?? FileSources.local; process.env.CDN_PROVIDER = fileStrategy; @@ -19,6 +27,7 @@ const AppService = async (app) => { } app.locals = { + socialLogins, fileStrategy, paths, }; diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index 769e4fb1e..ab99f2aa7 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -11,7 +11,7 @@ const configPath = path.resolve(projectRoot, 'librechat.yaml'); * Load custom configuration files and caches the object if the `cache` field at root is true. * Validation via parsing the config file with the config schema. * @function loadCustomConfig - * @returns {Promise} A promise that resolves to null or the custom config object. + * @returns {Promise} A promise that resolves to null or the custom config object. * */ async function loadCustomConfig() { diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index b7eeb59a5..d38673a6b 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -6,12 +6,12 @@ import { useAuthContext } from '~/hooks/AuthContext'; import { getLoginError } from '~/utils'; import { useLocalize } from '~/hooks'; import LoginForm from './LoginForm'; +import SocialButton from './SocialButton'; function Login() { const { login, error, isAuthenticated } = useAuthContext(); const { data: startupConfig } = useGetStartupConfig(); const localize = useLocalize(); - const navigate = useNavigate(); useEffect(() => { @@ -20,9 +20,79 @@ function Login() { } }, [isAuthenticated, navigate]); + if (!startupConfig) { + return null; + } + + const socialLogins = startupConfig.socialLogins ?? []; + + const providerComponents = { + discord: ( + + ), + facebook: ( + + ), + github: ( + + ), + google: ( + + ), + openid: ( + + startupConfig.openidImageUrl ? ( + OpenID Logo + ) : ( + + ) + } + label={startupConfig.openidLabel} + id="openid" + /> + ), + }; + return (
-
+

{localize('com_auth_welcome_back')}

@@ -34,95 +104,28 @@ function Login() { {localize(getLoginError(error))}
)} - {startupConfig?.emailLoginEnabled && } - {startupConfig?.registrationEnabled && ( + {startupConfig.emailLoginEnabled && } + {startupConfig.registrationEnabled && (

{' '} {localize('com_auth_no_account')}{' '} - + {localize('com_auth_sign_up')}

)} - {startupConfig?.socialLoginEnabled && startupConfig?.emailLoginEnabled && ( + {startupConfig.socialLoginEnabled && ( <> -
-
Or
-
-
- - )} - {startupConfig?.googleLoginEnabled && startupConfig?.socialLoginEnabled && ( - <> - - - )} - {startupConfig?.facebookLoginEnabled && startupConfig?.socialLoginEnabled && ( - <> - - - )} - {startupConfig?.openidLoginEnabled && startupConfig?.socialLoginEnabled && ( - <> - - - )} - {startupConfig?.githubLoginEnabled && startupConfig?.socialLoginEnabled && ( - <> - - - )} - {startupConfig?.discordLoginEnabled && startupConfig?.socialLoginEnabled && ( - <> -
- - -

{localize('com_auth_discord_login')}

-
+ {startupConfig.emailLoginEnabled && ( + <> +
+
Or
+
+
+ + )} +
+ {socialLogins.map((provider) => providerComponents[provider] || null)}
)} diff --git a/client/src/components/Auth/LoginForm.tsx b/client/src/components/Auth/LoginForm.tsx index eddb824f7..92cac25bc 100644 --- a/client/src/components/Auth/LoginForm.tsx +++ b/client/src/components/Auth/LoginForm.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { useForm } from 'react-hook-form'; import { useLocalize } from '~/hooks'; import { TLoginUser } from 'librechat-data-provider'; @@ -6,15 +7,23 @@ type TLoginFormProps = { onSubmit: (data: TLoginUser) => void; }; -function LoginForm({ onSubmit }: TLoginFormProps) { +const LoginForm: React.FC = ({ onSubmit }) => { const localize = useLocalize(); - const { register, handleSubmit, formState: { errors }, } = useForm(); + const renderError = (fieldName: string) => { + const errorMessage = errors[fieldName]?.message; + return errorMessage ? ( + + {String(errorMessage)} + + ) : null; + }; + return (
+ />
- {errors.email && ( - - {/* @ts-ignore not sure why*/} - {errors.email.message} - - )} + {renderError('email')}
@@ -71,19 +66,13 @@ function LoginForm({ onSubmit }: TLoginFormProps) { aria-label={localize('com_auth_password')} {...register('password', { required: localize('com_auth_password_required'), - minLength: { - value: 8, - message: localize('com_auth_password_min_length'), - }, - maxLength: { - value: 128, - message: localize('com_auth_password_max_length'), - }, + minLength: { value: 8, message: localize('com_auth_password_min_length') }, + maxLength: { value: 128, message: localize('com_auth_password_max_length') }, })} aria-invalid={!!errors.password} - className="peer block w-full appearance-none rounded-md border border-gray-300 bg-gray-50 px-2.5 pb-2.5 pt-5 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0" + className="peer block w-full appearance-none rounded-md border border-gray-300 bg-white px-2.5 pb-2.5 pt-5 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0" placeholder=" " - > + />
- - {errors.password && ( - - {/* @ts-ignore not sure why*/} - {errors.password.message} - - )} + {renderError('password')}
- + {localize('com_auth_password_forgot')}
@@ -107,12 +90,13 @@ function LoginForm({ onSubmit }: TLoginFormProps) { aria-label="Sign in" data-testid="login-button" type="submit" - className="w-full transform rounded-md bg-green-500 px-4 py-3 tracking-wide text-white transition-colors duration-200 hover:bg-green-600 focus:bg-green-600 focus:outline-none"> + className="w-full transform rounded-md bg-green-500 px-4 py-3 tracking-wide text-white transition-all duration-300 hover:bg-green-550 focus:bg-green-550 focus:outline-none" + > {localize('com_auth_continue')}
); -} +}; export default LoginForm; diff --git a/client/src/components/Auth/Registration.tsx b/client/src/components/Auth/Registration.tsx index 9ef960488..759b8593e 100644 --- a/client/src/components/Auth/Registration.tsx +++ b/client/src/components/Auth/Registration.tsx @@ -1,15 +1,15 @@ +import React, { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; -import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useRegisterUserMutation, useGetStartupConfig } from 'librechat-data-provider/react-query'; import type { TRegisterUser } from 'librechat-data-provider'; import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon } from '~/components'; import { useLocalize } from '~/hooks'; +import SocialButton from './SocialButton'; -function Registration() { +const Registration: React.FC = () => { const navigate = useNavigate(); const { data: startupConfig } = useGetStartupConfig(); - const localize = useLocalize(); const { @@ -22,23 +22,20 @@ function Registration() { const [error, setError] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const registerUser = useRegisterUserMutation(); - const password = watch('password'); - const onRegisterUserFormSubmit = (data: TRegisterUser) => { - registerUser.mutate(data, { - onSuccess: () => { - navigate('/c/new'); - }, - onError: (error) => { - setError(true); + const onRegisterUserFormSubmit = async (data: TRegisterUser) => { + try { + await registerUser.mutateAsync(data); + navigate('/c/new'); + } catch (error) { + setError(true); + //@ts-ignore - error is of type unknown + if (error.response?.data?.message) { //@ts-ignore - error is of type unknown - if (error.response?.data?.message) { - //@ts-ignore - error is of type unknown - setErrorMessage(error.response?.data?.message); - } - }, - }); + setErrorMessage(error.response?.data?.message); + } + } }; useEffect(() => { @@ -47,9 +44,111 @@ function Registration() { } }, [startupConfig, navigate]); + if (!startupConfig) { + return null; + } + + const socialLogins = startupConfig.socialLogins ?? []; + + const renderInput = (id: string, label: string, type: string, validation: object) => ( +
+
+ + +
+ {errors[id] && ( + + {String(errors[id]?.message) ?? ''} + + )} +
+ ); + + const providerComponents = { + discord: ( + + ), + facebook: ( + + ), + github: ( + + ), + google: ( + + ), + openid: ( + + startupConfig.openidImageUrl ? ( + OpenID Logo + ) : ( + + ) + } + label={startupConfig.openidLabel} + id="openid" + /> + ), + }; + return (
-
+

{localize('com_auth_create_account')}

@@ -66,204 +165,61 @@ function Registration() { className="mt-6" aria-label="Registration form" method="POST" - onSubmit={handleSubmit((data) => onRegisterUserFormSubmit(data))} + onSubmit={handleSubmit(onRegisterUserFormSubmit)} > -
-
- - -
- - {errors.name && ( - - {/* @ts-ignore not sure why*/} - {errors.name.message} - - )} -
-
-
- - -
- - {errors.username && ( - - {/* @ts-ignore not sure why */} - {errors.username.message} - - )} -
-
-
- - -
- {errors.email && ( - - {/* @ts-ignore - Type 'string | FieldError | Merge> | undefined' is not assignable to type 'ReactNode' */} - {errors.email.message} - - )} -
-
-
- - -
- - {errors.password && ( - - {/* @ts-ignore not sure why */} - {errors.password.message} - - )} -
-
-
- { - // e.preventDefault(); - // return false; - // }} - {...register('confirm_password', { - validate: (value) => - value === password || localize('com_auth_password_not_match'), - })} - aria-invalid={!!errors.confirm_password} - className="peer block w-full appearance-none rounded-md border border-gray-300 bg-gray-50 px-2.5 pb-2.5 pt-5 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0" - placeholder=" " - > - -
- - {errors.confirm_password && ( - - {/* @ts-ignore not sure why */} - {errors.confirm_password.message} - - )} -
+ {renderInput('name', 'com_auth_full_name', 'text', { + required: localize('com_auth_name_required'), + minLength: { + value: 3, + message: localize('com_auth_name_min_length'), + }, + maxLength: { + value: 80, + message: localize('com_auth_name_max_length'), + }, + })} + {renderInput('username', 'com_auth_username', 'text', { + minLength: { + value: 2, + message: localize('com_auth_username_min_length'), + }, + maxLength: { + value: 80, + message: localize('com_auth_username_max_length'), + }, + })} + {renderInput('email', 'com_auth_email', 'email', { + required: localize('com_auth_email_required'), + minLength: { + value: 3, + message: localize('com_auth_email_min_length'), + }, + maxLength: { + value: 120, + message: localize('com_auth_email_max_length'), + }, + pattern: { + value: /\S+@\S+\.\S+/, + message: localize('com_auth_email_pattern'), + }, + })} + {renderInput('password', 'com_auth_password', 'password', { + required: localize('com_auth_password_required'), + minLength: { + value: 8, + message: localize('com_auth_password_min_length'), + }, + maxLength: { + value: 128, + message: localize('com_auth_password_max_length'), + }, + })} + {renderInput('confirm_password', 'com_auth_password_confirm', 'password', { + validate: (value) => value === password || localize('com_auth_password_not_match'), + })}

- {' '} {localize('com_auth_already_have_account')}{' '}

- {startupConfig?.socialLoginEnabled && ( + {startupConfig.socialLoginEnabled && ( <> -
-
Or
-
-
- - )} - {startupConfig?.googleLoginEnabled && startupConfig?.socialLoginEnabled && ( - <> - - - )} - {startupConfig?.facebookLoginEnabled && startupConfig?.socialLoginEnabled && ( - <> - - - )} - {startupConfig?.openidLoginEnabled && startupConfig?.socialLoginEnabled && ( - <> - - - )} - {startupConfig?.githubLoginEnabled && startupConfig?.socialLoginEnabled && ( - <> - - - )} - {startupConfig?.discordLoginEnabled && startupConfig?.socialLoginEnabled && ( - <> -
- - -

{localize('com_auth_discord_login')}

-
+ {startupConfig.emailLoginEnabled && ( + <> +
+
Or
+
+
+ + )} +
+ {socialLogins.map((provider) => providerComponents[provider] || null)}
)}
); -} +}; export default Registration; diff --git a/client/src/components/Auth/RequestPasswordReset.tsx b/client/src/components/Auth/RequestPasswordReset.tsx index 4980b4f27..f3249b70f 100644 --- a/client/src/components/Auth/RequestPasswordReset.tsx +++ b/client/src/components/Auth/RequestPasswordReset.tsx @@ -61,9 +61,86 @@ function RequestPasswordReset() { } }, [requestPasswordReset.isSuccess, config.data?.emailEnabled, resetLink, localize]); + const renderFormContent = () => { + if (bodyText) { + return ( +
+ {bodyText} +
+ ); + } else { + return ( +
+
+
+ + +
+ {errors.email && ( + + {/* @ts-ignore not sure why */} + {errors.email.message} + + )} +
+
+ + +
+
+ ); + } + }; + return (
-
+

{headerText}

{requestError && (
)} - {bodyText ? ( -
- {bodyText} -
- ) : ( -
-
-
- - -
- {errors.email && ( - - {/* @ts-ignore not sure why */} - {errors.email.message} - - )} -
-
- -
-
- )} + {renderFormContent()}
); diff --git a/client/src/components/Auth/ResetPassword.tsx b/client/src/components/Auth/ResetPassword.tsx index 664c95377..f400cca69 100644 --- a/client/src/components/Auth/ResetPassword.tsx +++ b/client/src/components/Auth/ResetPassword.tsx @@ -30,7 +30,7 @@ function ResetPassword() { if (resetPassword.isSuccess) { return (
-
+

{localize('com_auth_reset_password_success')}

@@ -53,7 +53,7 @@ function ResetPassword() { } else { return (
-
+

{localize('com_auth_reset_password')}

@@ -176,10 +176,13 @@ function ResetPassword() { disabled={!!errors.password || !!errors.confirm_password} type="submit" aria-label={localize('com_auth_submit_registration')} - className="w-full transform rounded-md bg-green-500 px-4 py-3 tracking-wide text-white transition-colors duration-200 hover:bg-green-600 focus:bg-green-600 focus:outline-none" + className="w-full transform rounded-md bg-green-500 px-4 py-3 tracking-wide text-white transition-all duration-300 hover:bg-green-550 focus:bg-green-550 focus:outline-none" > {localize('com_auth_continue')} + + {localize('com_auth_back_to_login')} +
diff --git a/client/src/components/Auth/SocialButton.tsx b/client/src/components/Auth/SocialButton.tsx new file mode 100644 index 000000000..80b01cec8 --- /dev/null +++ b/client/src/components/Auth/SocialButton.tsx @@ -0,0 +1,78 @@ +import React, { useState } from 'react'; + +const SocialButton = ({ id, enabled, serverDomain, oauthPath, Icon, label }) => { + const [isHovered, setIsHovered] = useState(false); + const [isPressed, setIsPressed] = useState(false); + + // New state to keep track of the currently pressed button + const [activeButton, setActiveButton] = useState(null); + + if (!enabled) { + return null; + } + + const handleMouseEnter = () => { + setIsHovered(true); + }; + + const handleMouseLeave = () => { + setIsHovered(false); + }; + + const handleMouseDown = () => { + setIsPressed(true); + setActiveButton(id); + }; + + const handleMouseUp = () => { + setIsPressed(false); + }; + + const getButtonStyles = () => { + const baseStyles = { + border: '1px solid #CCCCCC', + transition: 'background-color 0.3s ease, border 0.3s ease', + }; + + if (isPressed && activeButton === id) { + return { + ...baseStyles, + backgroundColor: '#B9DAE9', + border: '2px solid #B9DAE9', + }; + } + + if (isHovered) { + return { + ...baseStyles, + backgroundColor: '#E5E5E5', + }; + } + + return { + ...baseStyles, + backgroundColor: 'transparent', + }; + }; + + return ( + + ); +}; + +export default SocialButton; diff --git a/client/src/components/Auth/__tests__/Login.spec.tsx b/client/src/components/Auth/__tests__/Login.spec.tsx index 5a70a5fec..a076ba5c9 100644 --- a/client/src/components/Auth/__tests__/Login.spec.tsx +++ b/client/src/components/Auth/__tests__/Login.spec.tsx @@ -31,13 +31,14 @@ const setup = ({ isLoading: false, isError: false, data: { - googleLoginEnabled: true, + socialLogins: ['google', 'facebook', 'openid', 'github', 'discord'], + discordLoginEnabled: true, facebookLoginEnabled: true, + githubLoginEnabled: true, + googleLoginEnabled: true, openidLoginEnabled: true, openidLabel: 'Test OpenID', openidImageUrl: 'http://test-server.com', - githubLoginEnabled: true, - discordLoginEnabled: true, registrationEnabled: true, emailLoginEnabled: true, socialLoginEnabled: true, @@ -78,23 +79,23 @@ test('renders login form', () => { expect(getByRole('button', { name: /Sign in/i })).toBeInTheDocument(); expect(getByRole('link', { name: /Sign up/i })).toBeInTheDocument(); expect(getByRole('link', { name: /Sign up/i })).toHaveAttribute('href', '/register'); - expect(getByRole('link', { name: /Login with Google/i })).toBeInTheDocument(); - expect(getByRole('link', { name: /Login with Google/i })).toHaveAttribute( + expect(getByRole('link', { name: /Continue with Google/i })).toBeInTheDocument(); + expect(getByRole('link', { name: /Continue with Google/i })).toHaveAttribute( 'href', 'mock-server/oauth/google', ); - expect(getByRole('link', { name: /Login with Facebook/i })).toBeInTheDocument(); - expect(getByRole('link', { name: /Login with Facebook/i })).toHaveAttribute( + expect(getByRole('link', { name: /Continue with Facebook/i })).toBeInTheDocument(); + expect(getByRole('link', { name: /Continue with Facebook/i })).toHaveAttribute( 'href', 'mock-server/oauth/facebook', ); - expect(getByRole('link', { name: /Login with Github/i })).toBeInTheDocument(); - expect(getByRole('link', { name: /Login with Github/i })).toHaveAttribute( + expect(getByRole('link', { name: /Continue with Github/i })).toBeInTheDocument(); + expect(getByRole('link', { name: /Continue with Github/i })).toHaveAttribute( 'href', 'mock-server/oauth/github', ); - expect(getByRole('link', { name: /Login with Discord/i })).toBeInTheDocument(); - expect(getByRole('link', { name: /Login with Discord/i })).toHaveAttribute( + expect(getByRole('link', { name: /Continue with Discord/i })).toBeInTheDocument(); + expect(getByRole('link', { name: /Continue with Discord/i })).toHaveAttribute( 'href', 'mock-server/oauth/discord', ); diff --git a/client/src/components/Auth/__tests__/Registration.spec.tsx b/client/src/components/Auth/__tests__/Registration.spec.tsx index 324d593a1..d4a989007 100644 --- a/client/src/components/Auth/__tests__/Registration.spec.tsx +++ b/client/src/components/Auth/__tests__/Registration.spec.tsx @@ -32,14 +32,14 @@ const setup = ({ isLoading: false, isError: false, data: { - googleLoginEnabled: true, + socialLogins: ['google', 'facebook', 'openid', 'github', 'discord'], + discordLoginEnabled: true, facebookLoginEnabled: true, + githubLoginEnabled: true, + googleLoginEnabled: true, openidLoginEnabled: true, openidLabel: 'Test OpenID', openidImageUrl: 'http://test-server.com', - githubLoginEnabled: true, - discordLoginEnabled: true, - emailLoginEnabled: true, registrationEnabled: true, socialLoginEnabled: true, serverDomain: 'mock-server', @@ -85,23 +85,23 @@ test('renders registration form', () => { expect(getByRole('button', { name: /Submit registration/i })).toBeInTheDocument(); expect(getByRole('link', { name: 'Login' })).toBeInTheDocument(); expect(getByRole('link', { name: 'Login' })).toHaveAttribute('href', '/login'); - expect(getByRole('link', { name: /Login with Google/i })).toBeInTheDocument(); - expect(getByRole('link', { name: /Login with Google/i })).toHaveAttribute( + expect(getByRole('link', { name: /Continue with Google/i })).toBeInTheDocument(); + expect(getByRole('link', { name: /Continue with Google/i })).toHaveAttribute( 'href', 'mock-server/oauth/google', ); - expect(getByRole('link', { name: /Login with Facebook/i })).toBeInTheDocument(); - expect(getByRole('link', { name: /Login with Facebook/i })).toHaveAttribute( + expect(getByRole('link', { name: /Continue with Facebook/i })).toBeInTheDocument(); + expect(getByRole('link', { name: /Continue with Facebook/i })).toHaveAttribute( 'href', 'mock-server/oauth/facebook', ); - expect(getByRole('link', { name: /Login with Github/i })).toBeInTheDocument(); - expect(getByRole('link', { name: /Login with Github/i })).toHaveAttribute( + expect(getByRole('link', { name: /Continue with Github/i })).toBeInTheDocument(); + expect(getByRole('link', { name: /Continue with Github/i })).toHaveAttribute( 'href', 'mock-server/oauth/github', ); - expect(getByRole('link', { name: /Login with Discord/i })).toBeInTheDocument(); - expect(getByRole('link', { name: /Login with Discord/i })).toHaveAttribute( + expect(getByRole('link', { name: /Continue with Discord/i })).toBeInTheDocument(); + expect(getByRole('link', { name: /Continue with Discord/i })).toHaveAttribute( 'href', 'mock-server/oauth/discord', ); diff --git a/client/src/localization/languages/Eng.tsx b/client/src/localization/languages/Eng.tsx index c49c0d0df..f92dd2df7 100644 --- a/client/src/localization/languages/Eng.tsx +++ b/client/src/localization/languages/Eng.tsx @@ -71,10 +71,10 @@ export default { com_auth_no_account: 'Don\'t have an account?', com_auth_sign_up: 'Sign up', com_auth_sign_in: 'Sign in', - com_auth_google_login: 'Login with Google', - com_auth_facebook_login: 'Login with Facebook', - com_auth_github_login: 'Login with Github', - com_auth_discord_login: 'Login with Discord', + com_auth_google_login: 'Continue with Google', + com_auth_facebook_login: 'Continue with Facebook', + com_auth_github_login: 'Continue with Github', + com_auth_discord_login: 'Continue with Discord', com_auth_email: 'Email', com_auth_email_required: 'Email is required', com_auth_email_min_length: 'Email must be at least 6 characters', @@ -118,6 +118,7 @@ export default { com_auth_to_try_again: 'to try again.', com_auth_submit_registration: 'Submit registration', com_auth_welcome_back: 'Welcome back', + com_auth_back_to_login: 'Back to Login', com_endpoint_open_menu: 'Open Menu', com_endpoint_bing_enable_sydney: 'Enable Sydney', com_endpoint_bing_to_enable_sydney: 'To enable Sydney', diff --git a/client/src/localization/languages/It.tsx b/client/src/localization/languages/It.tsx index 094cbb3e8..acb8b8ffb 100644 --- a/client/src/localization/languages/It.tsx +++ b/client/src/localization/languages/It.tsx @@ -71,10 +71,10 @@ export default { com_auth_no_account: 'Non hai un account?', com_auth_sign_up: 'Registrati', com_auth_sign_in: 'Accedi', - com_auth_google_login: 'Accedi con Google', - com_auth_facebook_login: 'Accedi con Facebook', - com_auth_github_login: 'Accedi con Github', - com_auth_discord_login: 'Accedi con Discord', + com_auth_google_login: 'Continua con Google', + com_auth_facebook_login: 'Continua con Facebook', + com_auth_github_login: 'Continua con Github', + com_auth_discord_login: 'Continua con Discord', com_auth_email: 'Email', com_auth_email_required: 'L\'email è obbligatoria', com_auth_email_min_length: 'L\'email deve essere lunga almeno 6 caratteri', diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index 2579547d0..10f17abf1 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -14,6 +14,9 @@ module.exports = { mono: ['Söhne Mono', 'monospace'], }, extend: { + width: { + 'authPageWidth': '370px', + }, keyframes: { 'accordion-down': { from: { height: 0 }, @@ -51,6 +54,7 @@ module.exports = { 300: '#6dc8b9', 400: '#41a79d', 500: '#10a37f', + 550: '#349072', 600: '#126e6b', 700: '#0a4f53', 800: '#06373e', diff --git a/docs/install/configuration/custom_config.md b/docs/install/configuration/custom_config.md index 73652f590..a45cd69a4 100644 --- a/docs/install/configuration/custom_config.md +++ b/docs/install/configuration/custom_config.md @@ -31,6 +31,7 @@ Stay tuned for ongoing enhancements to customize your LibreChat instance! - [Registration](#registration) - [Endpoints](#endpoints) - [Registration Object Structure](#registration-object-structure) + - [**socialLogins**:](#socialLogins) - [**allowedDomains**:](#allowedDomains) - [Custom Endpoint Object Structure](#custom-endpoint-object-structure) - [**name**:](#name) @@ -112,6 +113,9 @@ docker-compose up # no need to rebuild - **Key**: `registration` - **Type**: Object - **Description**: Configures registration-related settings for the application. + - **Sub-Key**: `socialLogins` + - **Type**: Array of Strings (`"google"`, `"facebook"`, `"openid"`, `"github"`, `"discord"`) + - **Description**: Determines both the available social login providers and their arranged order to list on the login/registration page, from top to bottom (first to last values). Note: a login option will not appear even if listed if not [properly configured.](./user_auth_system.md#social-authentication-setup-and-configuration) - **Sub-Key**: `allowedDomains` - **Type**: Array of Strings - **Description**: Specifies a list of allowed email domains for user registration. Users attempting to register with email domains not listed here will be restricted from registering. @@ -132,11 +136,23 @@ docker-compose up # no need to rebuild ```yaml # Example Registration Object Structure registration: + socialLogins: ["google", "facebook", "github", "discord", "openid"] allowedDomains: - "gmail.com" - "protonmail.com" ``` +### **socialLogins**: + + > Defines the available social login providers and their display order. + + - Type: Array of Strings + - Example: + ```yaml + socialLogins: ["google", "facebook", "github", "discord", "openid"] + ``` + - **Note**: The order of the providers in the list determines their appearance order on the login/registration page. Each provider listed must be [properly configured](./user_auth_system.md#social-authentication-setup-and-configuration) within the system to be active and available for users. This configuration allows for a tailored authentication experience, emphasizing the most relevant or preferred social login options for your user base. + ### **allowedDomains**: > A list specifying allowed email domains for registration. diff --git a/docs/install/configuration/dotenv.md b/docs/install/configuration/dotenv.md index 3dff8728a..0a82814d9 100644 --- a/docs/install/configuration/dotenv.md +++ b/docs/install/configuration/dotenv.md @@ -666,7 +666,6 @@ see: **[User/Auth System](../configuration/user_auth_system.md)** ```bash ALLOW_EMAIL_LOGIN=true ALLOW_REGISTRATION=true -ALLOWED_REGISTRATION_DOMAINS= ALLOW_SOCIAL_LOGIN=false ALLOW_SOCIAL_REGISTRATION=false ``` diff --git a/librechat.example.yaml b/librechat.example.yaml index 0c61e400b..d3de1a60d 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -5,7 +5,8 @@ version: 1.0.2 cache: true # Example Registration Object Structure (optional) -# registration: +registration: + socialLogins: ["github", "google", "discord", "openid", "facebook"] # allowedDomains: # - "gmail.com" diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index a1853c816..55abfb9ec 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -1,6 +1,6 @@ { "name": "librechat-data-provider", - "version": "0.3.8", + "version": "0.3.9", "description": "data services for librechat apps", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 9357e9fa3..44c0a260b 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -32,6 +32,7 @@ export const configSchema = z.object({ fileStrategy: fileSourceSchema.optional(), registration: z .object({ + socialLogins: z.array(z.string()).optional(), allowedDomains: z.array(z.string()).optional(), }) .optional(), diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 21aeca8f7..9a498bd3f 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -189,13 +189,14 @@ export type TResetPassword = { export type TStartupConfig = { appTitle: string; - googleLoginEnabled: boolean; + socialLogins?: string[]; + discordLoginEnabled: boolean; facebookLoginEnabled: boolean; - openidLoginEnabled: boolean; githubLoginEnabled: boolean; + googleLoginEnabled: boolean; + openidLoginEnabled: boolean; openidLabel: string; openidImageUrl: string; - discordLoginEnabled: boolean; serverDomain: string; emailLoginEnabled: boolean; registrationEnabled: boolean;