diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 5038d17556..672dcad6e5 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -12,6 +12,7 @@ import type { TLoginUser, AuthTypeEnum, TConversation, + TStartupConfig, EModelEndpoint, AssistantsEndpoint, AuthorizationTypeEnum, @@ -390,3 +391,13 @@ export interface SwitcherProps { endpointKeyProvided: boolean; isCollapsed: boolean; } + +export type TLoginLayoutContext = { + startupConfig: TStartupConfig | null; + startupConfigError: unknown; + isFetching: boolean; + error: string | null; + setError: React.Dispatch>; + headerText: string; + setHeaderText: React.Dispatch>; +}; diff --git a/client/src/components/Auth/AuthLayout.tsx b/client/src/components/Auth/AuthLayout.tsx new file mode 100644 index 0000000000..c2d4a6303c --- /dev/null +++ b/client/src/components/Auth/AuthLayout.tsx @@ -0,0 +1,90 @@ +import { ThemeSelector } from '~/components/ui'; +import { useLocalize } from '~/hooks'; +import { BlinkAnimation } from './BlinkAnimation'; +import { TStartupConfig } from 'librechat-data-provider'; +import SocialLoginRender from './SocialLoginRender'; +import Footer from './Footer'; + +const ErrorRender = ({ children }: { children: React.ReactNode }) => ( +
+
+ {children} +
+
+); + +function AuthLayout({ + children, + header, + isFetching, + startupConfig, + startupConfigError, + pathname, + error, +}: { + children: React.ReactNode; + header: React.ReactNode; + isFetching: boolean; + startupConfig: TStartupConfig | null | undefined; + startupConfigError: unknown | null | undefined; + pathname: string; + error: string | null; +}) { + const localize = useLocalize(); + + const DisplayError = () => { + if (startupConfigError !== null && startupConfigError !== undefined) { + return {localize('com_auth_error_login_server')}; + } else if (error === 'com_auth_error_invalid_reset_token') { + return ( + + {localize('com_auth_error_invalid_reset_token')}{' '} + + {localize('com_auth_click_here')} + {' '} + {localize('com_auth_to_try_again')} + + ); + } else if (error) { + return {localize(error)}; + } + return null; + }; + + return ( +
+ +
+ Logo +
+
+ +
+ +
+ +
+
+ {!startupConfigError && !isFetching && ( +

+ {header} +

+ )} + {children} + {(pathname.includes('login') || pathname.includes('register')) && ( + + )} +
+
+
+
+ ); +} + +export default AuthLayout; diff --git a/client/src/components/Auth/BlinkAnimation.tsx b/client/src/components/Auth/BlinkAnimation.tsx new file mode 100644 index 0000000000..4323a3a631 --- /dev/null +++ b/client/src/components/Auth/BlinkAnimation.tsx @@ -0,0 +1,29 @@ +export const BlinkAnimation = ({ + active, + children, +}: { + active: boolean; + children: React.ReactNode; +}) => { + const style = ` + @keyframes blink-animation { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } + }`; + + if (!active) { + return <>{children}; + } + + return ( + <> + +
{children}
+ + ); +}; diff --git a/client/src/components/Auth/ErrorMessage.tsx b/client/src/components/Auth/ErrorMessage.tsx new file mode 100644 index 0000000000..7d59044dce --- /dev/null +++ b/client/src/components/Auth/ErrorMessage.tsx @@ -0,0 +1,8 @@ +export const ErrorMessage = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); diff --git a/client/src/components/Auth/Footer.tsx b/client/src/components/Auth/Footer.tsx new file mode 100644 index 0000000000..4cda32feb8 --- /dev/null +++ b/client/src/components/Auth/Footer.tsx @@ -0,0 +1,45 @@ +import { useLocalize } from '~/hooks'; +import { TStartupConfig } from 'librechat-data-provider'; + +function Footer({ startupConfig }: { startupConfig: TStartupConfig | null | undefined }) { + const localize = useLocalize(); + if (!startupConfig) { + return null; + } + const privacyPolicy = startupConfig.interface?.privacyPolicy; + const termsOfService = startupConfig.interface?.termsOfService; + + const privacyPolicyRender = privacyPolicy?.externalUrl && ( + + {localize('com_ui_privacy_policy')} + + ); + + const termsOfServiceRender = termsOfService?.externalUrl && ( + + {localize('com_ui_terms_of_service')} + + ); + + return ( +
+ {privacyPolicyRender} + {privacyPolicyRender && termsOfServiceRender && ( +
+ )} + {termsOfServiceRender} +
+ ); +} + +export default Footer; diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index 8bff10c258..8336320de1 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -1,182 +1,30 @@ -import { useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useGetStartupConfig } from 'librechat-data-provider/react-query'; -import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon } from '~/components'; +import { useOutletContext } from 'react-router-dom'; import { useAuthContext } from '~/hooks/AuthContext'; -import { ThemeSelector } from '~/components/ui'; -import SocialButton from './SocialButton'; +import type { TLoginLayoutContext } from '~/common'; +import { ErrorMessage } from '~/components/Auth/ErrorMessage'; import { getLoginError } from '~/utils'; import { useLocalize } from '~/hooks'; import LoginForm from './LoginForm'; function Login() { - const { login, error, isAuthenticated } = useAuthContext(); - const { data: startupConfig } = useGetStartupConfig(); const localize = useLocalize(); - const navigate = useNavigate(); - - useEffect(() => { - if (isAuthenticated) { - navigate('/c/new', { replace: true }); - } - }, [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" - /> - ), - }; - - const privacyPolicy = startupConfig.interface?.privacyPolicy; - const termsOfService = startupConfig.interface?.termsOfService; - - const privacyPolicyRender = privacyPolicy?.externalUrl && ( - - {localize('com_ui_privacy_policy')} - - ); - - const termsOfServiceRender = termsOfService?.externalUrl && ( - - {localize('com_ui_terms_of_service')} - - ); + const { error, login } = useAuthContext(); + const { startupConfig } = useOutletContext(); return ( -
-
- Logo -
-
- -
-
-
-

- {localize('com_auth_welcome_back')} -

- {error && ( -
- {localize(getLoginError(error))} -
- )} - {startupConfig.emailLoginEnabled && } - {startupConfig.registrationEnabled && ( -

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

- )} - {startupConfig.socialLoginEnabled && ( - <> - {startupConfig.emailLoginEnabled && ( - <> -
-
- Or -
-
-
- - )} -
- {socialLogins.map((provider) => providerComponents[provider] || null)} -
- - )} -
-
-
- {privacyPolicyRender} - {privacyPolicyRender && termsOfServiceRender && ( -
- )} - {termsOfServiceRender} -
-
+ <> + {error && {localize(getLoginError(error))}} + {startupConfig?.emailLoginEnabled && } + {startupConfig?.registrationEnabled && ( +

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

+ )} + ); } diff --git a/client/src/components/Auth/Registration.tsx b/client/src/components/Auth/Registration.tsx index 6bdd55d972..a69acd663a 100644 --- a/client/src/components/Auth/Registration.tsx +++ b/client/src/components/Auth/Registration.tsx @@ -1,17 +1,16 @@ import { useForm } from 'react-hook-form'; -import { useNavigate } from 'react-router-dom'; import React, { useState, useEffect } from 'react'; -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 { ThemeSelector } from '~/components/ui'; -import SocialButton from './SocialButton'; +import { useNavigate, useOutletContext } 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 { useLocalize } from '~/hooks'; const Registration: React.FC = () => { const navigate = useNavigate(); - const { data: startupConfig } = useGetStartupConfig(); const localize = useLocalize(); + const { startupConfig, startupConfigError, isFetching } = useOutletContext(); const { register, @@ -31,10 +30,8 @@ const Registration: React.FC = () => { 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 - setErrorMessage(error.response?.data?.message); + if ((error as TError).response?.data?.message) { + setErrorMessage((error as TError).response?.data?.message ?? ''); } } }; @@ -45,12 +42,6 @@ const Registration: React.FC = () => { } }, [startupConfig, navigate]); - if (!startupConfig) { - return null; - } - - const socialLogins = startupConfig.socialLogins ?? []; - const renderInput = (id: string, label: string, type: string, validation: object) => (
@@ -67,7 +58,7 @@ const Registration: React.FC = () => { className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-gray-300 bg-transparent px-3.5 pb-3.5 pt-4 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-green-500" placeholder=" " data-testid={id} - > + />
); - const providerComponents = { - discord: ( - - ), - facebook: ( - - ), - github: ( - - ), - google: ( - - ), - openid: ( - - startupConfig.openidImageUrl ? ( - OpenID Logo - ) : ( - - ) - } - label={startupConfig.openidLabel} - id="openid" - /> - ), - }; - - const privacyPolicy = startupConfig.interface?.privacyPolicy; - const termsOfService = startupConfig.interface?.termsOfService; - - const privacyPolicyRender = privacyPolicy?.externalUrl && ( - - {localize('com_ui_privacy_policy')} - - ); - - const termsOfServiceRender = termsOfService?.externalUrl && ( - - {localize('com_ui_terms_of_service')} - - ); - return ( -
-
- Logo -
-
- -
-
-
-

- {localize('com_auth_create_account')} -

- {error && ( -
- {localize('com_auth_error_create')} {errorMessage} -
- )} + <> + {error && ( + + {localize('com_auth_error_create')} {errorMessage} + + )} + + {!startupConfigError && !isFetching && ( + <>
{ }, })} {renderInput('confirm_password', 'com_auth_password_confirm', 'password', { - validate: (value) => value === password || localize('com_auth_password_not_match'), + validate: (value: string) => + value === password || localize('com_auth_password_not_match'), })}
+

{localize('com_auth_already_have_account')}{' '} {localize('com_auth_login')}

- {startupConfig.socialLoginEnabled && ( - <> - {startupConfig.emailLoginEnabled && ( - <> -
-
- Or -
-
-
- - )} -
- {socialLogins.map((provider) => providerComponents[provider] || null)} -
- - )} -
-
-
- {privacyPolicyRender} - {privacyPolicyRender && termsOfServiceRender && ( -
- )} - {termsOfServiceRender} -
-
+ + )} + ); }; diff --git a/client/src/components/Auth/RequestPasswordReset.tsx b/client/src/components/Auth/RequestPasswordReset.tsx index 671ca48133..770a4bd523 100644 --- a/client/src/components/Auth/RequestPasswordReset.tsx +++ b/client/src/components/Auth/RequestPasswordReset.tsx @@ -1,11 +1,9 @@ import { useForm } from 'react-hook-form'; import { useState, useEffect } from 'react'; -import { - useGetStartupConfig, - useRequestPasswordResetMutation, -} from 'librechat-data-provider/react-query'; +import { useOutletContext } from 'react-router-dom'; +import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query'; import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider'; -import { ThemeSelector } from '~/components/ui'; +import type { TLoginLayoutContext } from '~/common'; import { useLocalize } from '~/hooks'; function RequestPasswordReset() { @@ -15,187 +13,131 @@ function RequestPasswordReset() { handleSubmit, formState: { errors }, } = useForm(); - const requestPasswordReset = useRequestPasswordResetMutation(); - const config = useGetStartupConfig(); - const [requestError, setRequestError] = useState(false); const [resetLink, setResetLink] = useState(undefined); - const [headerText, setHeaderText] = useState(''); const [bodyText, setBodyText] = useState(undefined); + const { startupConfig, setError, setHeaderText } = useOutletContext(); + + const requestPasswordReset = useRequestPasswordResetMutation(); const onSubmit = (data: TRequestPasswordReset) => { requestPasswordReset.mutate(data, { onSuccess: (data: TRequestPasswordResetResponse) => { - console.log('emailEnabled: ', config.data?.emailEnabled); - if (!config.data?.emailEnabled) { + if (!startupConfig?.emailEnabled) { setResetLink(data.link); } }, onError: () => { - setRequestError(true); + setError('com_auth_error_reset_password'); setTimeout(() => { - setRequestError(false); + setError(null); }, 5000); }, }); }; useEffect(() => { - if (requestPasswordReset.isSuccess) { - if (config.data?.emailEnabled) { - setHeaderText(localize('com_auth_reset_password_link_sent')); - setBodyText(localize('com_auth_reset_password_email_sent')); - } else { - setHeaderText(localize('com_auth_reset_password')); - setBodyText( - - {localize('com_auth_click')}{' '} - - {localize('com_auth_here')} - {' '} - {localize('com_auth_to_reset_your_password')} - , - ); - } - } else { - setHeaderText(localize('com_auth_reset_password')); + if (!requestPasswordReset.isSuccess) { + setHeaderText('com_auth_reset_password'); setBodyText(undefined); + return; } - }, [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} - - )} -
-
- - -
-
- ); + if (startupConfig?.emailEnabled) { + setHeaderText('com_auth_reset_password_link_sent'); + setBodyText(localize('com_auth_reset_password_email_sent')); + return; } - }; - const privacyPolicy = config.data?.interface?.privacyPolicy; - const termsOfService = config.data?.interface?.termsOfService; + setHeaderText('com_auth_reset_password'); + setBodyText( + + {localize('com_auth_click')}{' '} + + {localize('com_auth_here')} + {' '} + {localize('com_auth_to_reset_your_password')} + , + ); + }, [ + requestPasswordReset.isSuccess, + startupConfig?.emailEnabled, + resetLink, + localize, + setHeaderText, + ]); - const privacyPolicyRender = privacyPolicy?.externalUrl && ( - - {localize('com_ui_privacy_policy')} - - ); - - const termsOfServiceRender = termsOfService?.externalUrl && ( - - {localize('com_ui_terms_of_service')} - - ); + if (bodyText) { + return ( +
+ {bodyText} +
+ ); + } return ( -
-
- Logo +
+
+
+ + +
+ {errors.email && ( + + {errors.email.message} + + )}
-
- -
-
-
-

- {headerText} -

- {requestError && ( -
- {localize('com_auth_error_reset_password')} -
- )} - {renderFormContent()} +
+ +
-
- {privacyPolicyRender} - {privacyPolicyRender && termsOfServiceRender && ( -
- )} - {termsOfServiceRender} -
-
+ ); } diff --git a/client/src/components/Auth/ResetPassword.tsx b/client/src/components/Auth/ResetPassword.tsx index bb5f3f3a77..d5a627a9bb 100644 --- a/client/src/components/Auth/ResetPassword.tsx +++ b/client/src/components/Auth/ResetPassword.tsx @@ -1,9 +1,9 @@ -import { useState } from 'react'; import { useForm } from 'react-hook-form'; +import { useOutletContext } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { useGetStartupConfig, useResetPasswordMutation } from 'librechat-data-provider/react-query'; +import { useResetPasswordMutation } from 'librechat-data-provider/react-query'; import type { TResetPassword } from 'librechat-data-provider'; -import { ThemeSelector } from '~/components/ui'; +import type { TLoginLayoutContext } from '~/common'; import { useLocalize } from '~/hooks'; function ResetPassword() { @@ -14,218 +14,146 @@ function ResetPassword() { watch, formState: { errors }, } = useForm(); - const resetPassword = useResetPasswordMutation(); - const config = useGetStartupConfig(); - const [resetError, setResetError] = useState(false); - const [params] = useSearchParams(); const navigate = useNavigate(); + const [params] = useSearchParams(); const password = watch('password'); + const resetPassword = useResetPasswordMutation(); + const { setError, setHeaderText } = useOutletContext(); const onSubmit = (data: TResetPassword) => { resetPassword.mutate(data, { onError: () => { - setResetError(true); + setError('com_auth_error_invalid_reset_token'); + }, + onSuccess: () => { + setHeaderText('com_auth_reset_password_success'); }, }); }; - const privacyPolicy = config.data?.interface?.privacyPolicy; - const termsOfService = config.data?.interface?.termsOfService; - - const privacyPolicyRender = privacyPolicy?.externalUrl && ( - - {localize('com_ui_privacy_policy')} - - ); - - const termsOfServiceRender = termsOfService?.externalUrl && ( - - {localize('com_ui_terms_of_service')} - - ); - if (resetPassword.isSuccess) { return ( -
-
- + <> +
+ {localize('com_auth_login_with_new_password')}
-
-

- {localize('com_auth_reset_password_success')} -

-
- {localize('com_auth_login_with_new_password')} -
- -
-
- ); - } else { - return ( -
-
- Logo -
-
- -
-
-
-

- {localize('com_auth_reset_password')} -

- {resetError && ( -
- {localize('com_auth_error_invalid_reset_token')}{' '} - - {localize('com_auth_click_here')} - {' '} - {localize('com_auth_to_try_again')} -
- )} -
-
-
- - - - -
- - {errors.password && ( - - {/* @ts-ignore not sure why */} - {errors.password.message} - - )} -
-
-
- - value === password || localize('com_auth_password_not_match'), - })} - aria-invalid={!!errors.confirm_password} - className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-gray-300 bg-transparent px-3.5 pb-3.5 pt-4 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-green-500" - placeholder=" " - > - -
- {errors.confirm_password && ( - - {/* @ts-ignore not sure why */} - {errors.confirm_password.message} - - )} - {errors.token && ( - - {/* @ts-ignore not sure why */} - {errors.token.message} - - )} - {errors.userId && ( - - {/* @ts-ignore not sure why */} - {errors.userId.message} - - )} -
-
- -
-
-
-
-
- {privacyPolicyRender} - {privacyPolicyRender && termsOfServiceRender && ( -
- )} - {termsOfServiceRender} -
-
+ + ); } + + return ( +
+
+
+ + + + +
+ + {errors.password && ( + + {errors.password.message} + + )} +
+
+
+ value === password || localize('com_auth_password_not_match'), + })} + aria-invalid={!!errors.confirm_password} + className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-gray-300 bg-transparent px-3.5 pb-3.5 pt-4 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-green-500" + placeholder=" " + /> + +
+ {errors.confirm_password && ( + + {errors.confirm_password.message} + + )} + {errors.token && ( + + {errors.token.message} + + )} + {errors.userId && ( + + {errors.userId.message} + + )} +
+
+ +
+
+ ); } export default ResetPassword; diff --git a/client/src/components/Auth/SocialLoginRender.tsx b/client/src/components/Auth/SocialLoginRender.tsx new file mode 100644 index 0000000000..58e68e28bf --- /dev/null +++ b/client/src/components/Auth/SocialLoginRender.tsx @@ -0,0 +1,105 @@ +import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon } from '~/components'; + +import SocialButton from './SocialButton'; + +import { useLocalize } from '~/hooks'; + +import { TStartupConfig } from 'librechat-data-provider'; + +function SocialLoginRender({ + startupConfig, +}: { + startupConfig: TStartupConfig | null | undefined; +}) { + const localize = useLocalize(); + + if (!startupConfig) { + return null; + } + + const providerComponents = { + discord: startupConfig?.discordLoginEnabled && ( + + ), + facebook: startupConfig?.facebookLoginEnabled && ( + + ), + github: startupConfig?.githubLoginEnabled && ( + + ), + google: startupConfig?.googleLoginEnabled && ( + + ), + openid: startupConfig?.openidLoginEnabled && ( + + startupConfig.openidImageUrl ? ( + OpenID Logo + ) : ( + + ) + } + label={startupConfig.openidLabel} + id="openid" + /> + ), + }; + + return ( + startupConfig.socialLoginEnabled && ( + <> + {startupConfig.emailLoginEnabled && ( + <> +
+
+ Or +
+
+
+ + )} +
+ {startupConfig.socialLogins?.map((provider) => providerComponents[provider] || null)} +
+ + ) + ); +} + +export default SocialLoginRender; diff --git a/client/src/components/Auth/__tests__/Login.spec.tsx b/client/src/components/Auth/__tests__/Login.spec.tsx index a076ba5c9b..0e332ea5fa 100644 --- a/client/src/components/Auth/__tests__/Login.spec.tsx +++ b/client/src/components/Auth/__tests__/Login.spec.tsx @@ -1,10 +1,33 @@ -import { render, waitFor } from 'test/layout-test-utils'; +import reactRouter from 'react-router-dom'; import userEvent from '@testing-library/user-event'; -import Login from '../Login'; +import { render, waitFor } from 'test/layout-test-utils'; import * as mockDataProvider from 'librechat-data-provider/react-query'; +import type { TStartupConfig } from 'librechat-data-provider'; +import AuthLayout from '~/components/Auth/AuthLayout'; +import Login from '~/components/Auth/Login'; jest.mock('librechat-data-provider/react-query'); +const mockStartupConfig = { + isFetching: false, + isLoading: false, + isError: false, + data: { + socialLogins: ['google', 'facebook', 'openid', 'github', 'discord'], + discordLoginEnabled: true, + facebookLoginEnabled: true, + githubLoginEnabled: true, + googleLoginEnabled: true, + openidLoginEnabled: true, + openidLabel: 'Test OpenID', + openidImageUrl: 'http://test-server.com', + registrationEnabled: true, + emailLoginEnabled: true, + socialLoginEnabled: true, + serverDomain: 'mock-server', + }, +}; + const setup = ({ useGetUserQueryReturnValue = { isLoading: false, @@ -27,24 +50,7 @@ const setup = ({ user: {}, }, }, - useGetStartupCongfigReturnValue = { - isLoading: false, - isError: false, - data: { - socialLogins: ['google', 'facebook', 'openid', 'github', 'discord'], - discordLoginEnabled: true, - facebookLoginEnabled: true, - githubLoginEnabled: true, - googleLoginEnabled: true, - openidLoginEnabled: true, - openidLabel: 'Test OpenID', - openidImageUrl: 'http://test-server.com', - registrationEnabled: true, - emailLoginEnabled: true, - socialLoginEnabled: true, - serverDomain: 'mock-server', - }, - }, + useGetStartupCongfigReturnValue = mockStartupConfig, } = {}) => { const mockUseLoginUser = jest .spyOn(mockDataProvider, 'useLoginUserMutation') @@ -62,16 +68,38 @@ const setup = ({ .spyOn(mockDataProvider, 'useRefreshTokenMutation') //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult .mockReturnValue(useRefreshTokenMutationReturnValue); - const renderResult = render(); + const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({ + startupConfig: useGetStartupCongfigReturnValue.data, + }); + const renderResult = render( + + + , + ); return { ...renderResult, mockUseLoginUser, mockUseGetUserQuery, + mockUseOutletContext, mockUseGetStartupConfig, mockUseRefreshTokenMutation, }; }; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useOutletContext: () => ({ + startupConfig: mockStartupConfig, + }), +})); + test('renders login form', () => { const { getByLabelText, getByRole } = setup(); expect(getByLabelText(/email/i)).toBeInTheDocument(); @@ -132,6 +160,14 @@ test('Navigates to / on successful login', async () => { isError: false, isSuccess: true, }, + useGetStartupCongfigReturnValue: { + ...mockStartupConfig, + data: { + ...mockStartupConfig.data, + emailLoginEnabled: true, + registrationEnabled: true, + }, + }, }); const emailInput = getByLabelText(/email/i); diff --git a/client/src/components/Auth/__tests__/Registration.spec.tsx b/client/src/components/Auth/__tests__/Registration.spec.tsx index d4a9890070..c65afc5cc1 100644 --- a/client/src/components/Auth/__tests__/Registration.spec.tsx +++ b/client/src/components/Auth/__tests__/Registration.spec.tsx @@ -1,10 +1,32 @@ -import { render, waitFor, screen } from 'test/layout-test-utils'; +import reactRouter from 'react-router-dom'; import userEvent from '@testing-library/user-event'; -import Registration from '../Registration'; +import { render, waitFor, screen } from 'test/layout-test-utils'; import * as mockDataProvider from 'librechat-data-provider/react-query'; +import type { TStartupConfig } from 'librechat-data-provider'; +import Registration from '~/components/Auth/Registration'; +import AuthLayout from '~/components/Auth/AuthLayout'; jest.mock('librechat-data-provider/react-query'); +const mockStartupConfig = { + isFetching: false, + isLoading: false, + isError: false, + data: { + socialLogins: ['google', 'facebook', 'openid', 'github', 'discord'], + discordLoginEnabled: true, + facebookLoginEnabled: true, + githubLoginEnabled: true, + googleLoginEnabled: true, + openidLoginEnabled: true, + openidLabel: 'Test OpenID', + openidImageUrl: 'http://test-server.com', + registrationEnabled: true, + socialLoginEnabled: true, + serverDomain: 'mock-server', + }, +}; + const setup = ({ useGetUserQueryReturnValue = { isLoading: false, @@ -28,23 +50,7 @@ const setup = ({ user: {}, }, }, - useGetStartupCongfigReturnValue = { - isLoading: false, - isError: false, - data: { - socialLogins: ['google', 'facebook', 'openid', 'github', 'discord'], - discordLoginEnabled: true, - facebookLoginEnabled: true, - githubLoginEnabled: true, - googleLoginEnabled: true, - openidLoginEnabled: true, - openidLabel: 'Test OpenID', - openidImageUrl: 'http://test-server.com', - registrationEnabled: true, - socialLoginEnabled: true, - serverDomain: 'mock-server', - }, - }, + useGetStartupCongfigReturnValue = mockStartupConfig, } = {}) => { const mockUseRegisterUserMutation = jest .spyOn(mockDataProvider, 'useRegisterUserMutation') @@ -62,17 +68,39 @@ const setup = ({ .spyOn(mockDataProvider, 'useRefreshTokenMutation') //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult .mockReturnValue(useRefreshTokenMutationReturnValue); - const renderResult = render(); + const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({ + startupConfig: useGetStartupCongfigReturnValue.data, + }); + const renderResult = render( + + + , + ); return { ...renderResult, - mockUseRegisterUserMutation, mockUseGetUserQuery, + mockUseOutletContext, mockUseGetStartupConfig, + mockUseRegisterUserMutation, mockUseRefreshTokenMutation, }; }; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useOutletContext: () => ({ + startupConfig: mockStartupConfig, + }), +})); + test('renders registration form', () => { const { getByText, getByTestId, getByRole } = setup(); expect(getByText(/Create your account/i)).toBeInTheDocument(); diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx index 0a0dcb166d..29919e48a3 100644 --- a/client/src/components/Chat/Header.tsx +++ b/client/src/components/Chat/Header.tsx @@ -4,9 +4,8 @@ import { getConfigDefaults } from 'librechat-data-provider'; import { useGetStartupConfig } from 'librechat-data-provider/react-query'; import type { ContextType } from '~/common'; import { EndpointsMenu, ModelSpecsMenu, PresetsMenu, HeaderNewChat } from './Menus'; -import HeaderOptions from './Input/HeaderOptions'; -import ExportButton from './ExportButton'; import ExportAndShareMenu from './ExportAndShareMenu'; +import HeaderOptions from './Input/HeaderOptions'; const defaultInterface = getConfigDefaults().interface; diff --git a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx index ae781dfd81..e0149c2669 100644 --- a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { Import } from 'lucide-react'; +import type { TError } from 'librechat-data-provider'; import { useUploadConversationsMutation } from '~/data-provider'; import { useLocalize, useConversations } from '~/hooks'; import { useToastContext } from '~/Providers'; @@ -25,8 +26,7 @@ function ImportConversations() { console.error('Error: ', error); setAllowImport(true); setError( - (error as { response: { data: { message?: string } } })?.response?.data?.message ?? - 'An error occurred while uploading the file.', + (error as TError)?.response?.data?.message ?? 'An error occurred while uploading the file.', ); if (error?.toString().includes('Unsupported import type')) { showToast({ diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index 711f433ef2..cbd0080e05 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -83,6 +83,7 @@ const AuthContextProvider = ({ loginUser.mutate(data, { onSuccess: (data: TLoginResponse) => { const { user, token } = data; + setError(undefined); setUserContext({ token, isAuthenticated: true, user, redirect: '/c/new' }); }, onError: (error: TResError | unknown) => { diff --git a/client/src/hooks/Files/useFileHandling.ts b/client/src/hooks/Files/useFileHandling.ts index 3a83b452b8..e814763273 100644 --- a/client/src/hooks/Files/useFileHandling.ts +++ b/client/src/hooks/Files/useFileHandling.ts @@ -12,7 +12,7 @@ import { defaultAssistantsVersion, fileConfig as defaultFileConfig, } from 'librechat-data-provider'; -import type { TEndpointsConfig } from 'librechat-data-provider'; +import type { TEndpointsConfig, TError } from 'librechat-data-provider'; import type { ExtendedFile, FileSetter } from '~/common'; import { useUploadFileMutation, useGetFileConfig } from '~/data-provider'; import { useDelayedUploadToast } from './useDelayedUploadToast'; @@ -118,8 +118,7 @@ const useFileHandling = (params?: UseFileHandling) => { clearUploadTimer(file_id as string); deleteFileById(file_id as string); setError( - (error as { response: { data: { message?: string } } })?.response?.data?.message ?? - 'An error occurred while uploading the file.', + (error as TError)?.response?.data?.message ?? 'An error occurred while uploading the file.', ); }, }); diff --git a/client/src/routes/Layouts/Login.tsx b/client/src/routes/Layouts/Login.tsx new file mode 100644 index 0000000000..0ac4269df4 --- /dev/null +++ b/client/src/routes/Layouts/Login.tsx @@ -0,0 +1,7 @@ +import { useAuthContext } from '~/hooks/AuthContext'; +import StartupLayout from './Startup'; + +export default function LoginLayout() { + const { isAuthenticated } = useAuthContext(); + return ; +} diff --git a/client/src/routes/Layouts/Startup.tsx b/client/src/routes/Layouts/Startup.tsx new file mode 100644 index 0000000000..5e8e431f8b --- /dev/null +++ b/client/src/routes/Layouts/Startup.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react'; +import { Outlet, useNavigate, useLocation } from 'react-router-dom'; +import { useGetStartupConfig } from 'librechat-data-provider/react-query'; +import type { TStartupConfig } from 'librechat-data-provider'; +import AuthLayout from '~/components/Auth/AuthLayout'; +import { useLocalize } from '~/hooks'; + +const headerMap = { + '/login': 'com_auth_welcome_back', + '/register': 'com_auth_create_account', + '/forgot-password': 'com_auth_reset_password', + '/reset-password': 'com_auth_reset_password', +}; + +export default function StartupLayout({ isAuthenticated }: { isAuthenticated?: boolean }) { + const [error, setError] = useState(null); + const [headerText, setHeaderText] = useState(null); + const [startupConfig, setStartupConfig] = useState(null); + const { + data, + isFetching, + error: startupConfigError, + } = useGetStartupConfig({ + enabled: isAuthenticated ? startupConfig === null : true, + }); + const localize = useLocalize(); + const navigate = useNavigate(); + const location = useLocation(); + + useEffect(() => { + if (isAuthenticated) { + navigate('/c/new', { replace: true }); + } + if (data) { + setStartupConfig(data); + } + }, [isAuthenticated, navigate, data]); + + useEffect(() => { + document.title = startupConfig?.appTitle || 'LibreChat'; + }, [startupConfig?.appTitle]); + + useEffect(() => { + setError(null); + setHeaderText(null); + }, [location.pathname]); + + const contextValue = { + error, + setError, + headerText, + setHeaderText, + startupConfigError, + startupConfig, + isFetching, + }; + + return ( + + + + ); +} diff --git a/client/src/routes/index.tsx b/client/src/routes/index.tsx index 89576fc47d..0f95cbde56 100644 --- a/client/src/routes/index.tsx +++ b/client/src/routes/index.tsx @@ -7,6 +7,8 @@ import { ApiErrorWatcher, } from '~/components/Auth'; import { AuthContextProvider } from '~/hooks/AuthContext'; +import StartupLayout from './Layouts/Startup'; +import LoginLayout from './Layouts/Login'; import ShareRoute from './ShareRoute'; import ChatRoute from './ChatRoute'; import Search from './Search'; @@ -20,28 +22,40 @@ const AuthLayout = () => ( ); export const router = createBrowserRouter([ - { - path: 'register', - element: , - }, - { - path: 'forgot-password', - element: , - }, - { - path: 'reset-password', - element: , - }, { path: 'share/:shareId', element: , }, + { + path: '/', + element: , + children: [ + { + path: 'register', + element: , + }, + { + path: 'forgot-password', + element: , + }, + { + path: 'reset-password', + element: , + }, + ], + }, { element: , children: [ { - path: 'login', - element: , + path: '/', + element: , + children: [ + { + path: 'login', + element: , + }, + ], }, { path: '/',