🔒 feat: password reset disable option; fix: account email error message (#2327)

* feat: password reset  disable option; fix: account email leak

* fix(LoginSpec): typo

* test: fixed LoginForm test

* fix: disable password reset when undefined

* refactor: use a helper function

* fix: tests

* feat: Remove unused error message in password reset process

* chore: Update password reset email message

* refactor: only allow password reset if explicitly allowed

* feat: Add password reset email service configuration check

The code changes in `checks.js` add a new function `checkPasswordReset()` that checks if the email service is configured when password reset is enabled. If the email service is not configured, a warning message is logged. This change ensures secure password reset functionality by prompting the user to configure the email service.

Co-authored-by: Berry-13 <root@Berry>
Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
Co-authored-by: Danny Avila <danny@librechat.ai>

* chore: remove import order rules

* refactor: simplify password reset logic and align against Observable Response Discrepancy

* chore: make password reset warning more prominent

* chore(AuthService): better logging for password resets, refactor requestPasswordReset to use req object, fix sendEmail error when email config is not present

* refactor: fix styling of password reset email message

* chore: add missing type for passwordResetEnabled, TStartupConfig

* fix(LoginForm): prevent login form flickering

* fix(ci): Update login form to use mocked startupConfig for rendering correctly

* refactor: Improve password reset UI, applies DRY

* chore: Add logging to password reset validation middleware

* chore(CONTRIBUTING): Update import order conventions

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
Co-authored-by: Berry-13 <root@Berry>
Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
This commit is contained in:
Marco Beretta 2024-06-06 17:39:36 +02:00 committed by GitHub
parent a7f5b57272
commit 5452d4c20c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 288 additions and 137 deletions

View file

@ -319,6 +319,7 @@ ALLOW_EMAIL_LOGIN=true
ALLOW_REGISTRATION=true ALLOW_REGISTRATION=true
ALLOW_SOCIAL_LOGIN=false ALLOW_SOCIAL_LOGIN=false
ALLOW_SOCIAL_REGISTRATION=false ALLOW_SOCIAL_REGISTRATION=false
ALLOW_PASSWORD_RESET=false
# ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out # ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out
SESSION_EXPIRY=1000 * 60 * 15 SESSION_EXPIRY=1000 * 60 * 15

View file

@ -67,39 +67,6 @@ module.exports = {
'react/display-name': ['off'], 'react/display-name': ['off'],
'no-unused-vars': ['error', { varsIgnorePattern: '^_' }], 'no-unused-vars': ['error', { varsIgnorePattern: '^_' }],
quotes: ['error', 'single'], quotes: ['error', 'single'],
'import/order': [
'warn',
{
groups: [
['builtin'], // Node.js standard libraries, react
['external'], // npm packages
['type'], // Type imports (TypeScript)
[
'internal', // Internal alias imports eg.(~/Component)
'parent', // Parent directory imports eg.(../ParentComponent)
'sibling', // Sibling imports eg.(./components/MyComponent)
'index',
'object',
],
],
// 'newlines-between': 'always', // Enforce new lines between groups
pathGroups: [
{
pattern: '{react,react-dom/**}',
group: 'builtin',
position: 'before',
},
{
pattern: '~/**',
group: 'internal',
position: 'before',
},
],
pathGroupsExcludedImportTypes: ['builtin', 'type'], // Exclude these types from the path group rule
warnOnUnassignedImports: true, // Warn for unassigned imports
// alphabetize: { order: 'asc', caseInsensitive: true }, // Alphabetize imports within each group
},
],
}, },
overrides: [ overrides: [
{ {

View file

@ -126,6 +126,18 @@ Apply the following naming conventions to branches, labels, and other Git-relate
- **Current Stance**: At present, this backend transition is of lower priority and might not be pursued. - **Current Stance**: At present, this backend transition is of lower priority and might not be pursued.
## 7. Module Import Conventions
- `npm` packages first,
- from shortest line (top) to longest (bottom)
- Followed by typescript types (pertains to data-provider and client workspaces)
- longest line (top) to shortest (bottom)
- types from package come first
- Lastly, local imports
- longest line (top) to shortest (bottom)
- imports with alias `~` treated the same as relative import with respect to line length
--- ---

View file

@ -39,7 +39,7 @@ const getUserController = async (req, res) => {
const resetPasswordRequestController = async (req, res) => { const resetPasswordRequestController = async (req, res) => {
try { try {
const resetService = await requestPasswordReset(req.body.email); const resetService = await requestPasswordReset(req);
if (resetService instanceof Error) { if (resetService instanceof Error) {
return res.status(400).json(resetService); return res.status(400).json(resetService);
} else { } else {

View file

@ -17,6 +17,7 @@ const validateMessageReq = require('./validateMessageReq');
const buildEndpointOption = require('./buildEndpointOption'); const buildEndpointOption = require('./buildEndpointOption');
const validateRegistration = require('./validateRegistration'); const validateRegistration = require('./validateRegistration');
const validateImageRequest = require('./validateImageRequest'); const validateImageRequest = require('./validateImageRequest');
const validatePasswordReset = require('./validatePasswordReset');
const moderateText = require('./moderateText'); const moderateText = require('./moderateText');
const noIndex = require('./noIndex'); const noIndex = require('./noIndex');
const importLimiters = require('./importLimiters'); const importLimiters = require('./importLimiters');
@ -40,6 +41,7 @@ module.exports = {
buildEndpointOption, buildEndpointOption,
validateRegistration, validateRegistration,
validateImageRequest, validateImageRequest,
validatePasswordReset,
validateModel, validateModel,
moderateText, moderateText,
noIndex, noIndex,

View file

@ -0,0 +1,13 @@
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
function validatePasswordReset(req, res, next) {
if (isEnabled(process.env.ALLOW_PASSWORD_RESET)) {
next();
} else {
logger.warn(`Password reset attempt while not allowed. IP: ${req.ip}`);
res.status(403).send('Password reset is not allowed.');
}
}
module.exports = validatePasswordReset;

View file

@ -1,6 +1,7 @@
const { isEnabled } = require('~/server/utils');
function validateRegistration(req, res, next) { function validateRegistration(req, res, next) {
const setting = process.env.ALLOW_REGISTRATION?.toLowerCase(); if (isEnabled(process.env.ALLOW_REGISTRATION)) {
if (setting === 'true') {
next(); next();
} else { } else {
res.status(403).send('Registration is not allowed.'); res.status(403).send('Registration is not allowed.');

View file

@ -25,6 +25,7 @@ afterEach(() => {
delete process.env.DOMAIN_SERVER; delete process.env.DOMAIN_SERVER;
delete process.env.ALLOW_REGISTRATION; delete process.env.ALLOW_REGISTRATION;
delete process.env.ALLOW_SOCIAL_LOGIN; delete process.env.ALLOW_SOCIAL_LOGIN;
delete process.env.ALLOW_PASSWORD_RESET;
delete process.env.LDAP_URL; delete process.env.LDAP_URL;
delete process.env.LDAP_BIND_DN; delete process.env.LDAP_BIND_DN;
delete process.env.LDAP_BIND_CREDENTIALS; delete process.env.LDAP_BIND_CREDENTIALS;
@ -55,6 +56,7 @@ describe.skip('GET /', () => {
process.env.DOMAIN_SERVER = 'http://test-server.com'; process.env.DOMAIN_SERVER = 'http://test-server.com';
process.env.ALLOW_REGISTRATION = 'true'; process.env.ALLOW_REGISTRATION = 'true';
process.env.ALLOW_SOCIAL_LOGIN = 'true'; process.env.ALLOW_SOCIAL_LOGIN = 'true';
process.env.ALLOW_PASSWORD_RESET = 'true';
process.env.LDAP_URL = 'Test LDAP URL'; process.env.LDAP_URL = 'Test LDAP URL';
process.env.LDAP_BIND_DN = 'Test LDAP Bind DN'; process.env.LDAP_BIND_DN = 'Test LDAP Bind DN';
process.env.LDAP_BIND_CREDENTIALS = 'Test LDAP Bind Credentials'; process.env.LDAP_BIND_CREDENTIALS = 'Test LDAP Bind Credentials';
@ -78,6 +80,7 @@ describe.skip('GET /', () => {
serverDomain: 'http://test-server.com', serverDomain: 'http://test-server.com',
emailLoginEnabled: 'true', emailLoginEnabled: 'true',
registrationEnabled: 'true', registrationEnabled: 'true',
passwordResetEnabled: 'true',
socialLoginEnabled: 'true', socialLoginEnabled: 'true',
}); });
}); });

View file

@ -15,6 +15,7 @@ const {
requireLdapAuth, requireLdapAuth,
requireLocalAuth, requireLocalAuth,
validateRegistration, validateRegistration,
validatePasswordReset,
} = require('../middleware'); } = require('../middleware');
const router = express.Router(); const router = express.Router();
@ -32,7 +33,12 @@ router.post(
); );
router.post('/refresh', refreshController); router.post('/refresh', refreshController);
router.post('/register', registerLimiter, checkBan, validateRegistration, registrationController); router.post('/register', registerLimiter, checkBan, validateRegistration, registrationController);
router.post('/requestPasswordReset', resetPasswordRequestController); router.post(
router.post('/resetPassword', resetPasswordController); '/requestPasswordReset',
checkBan,
validatePasswordReset,
resetPasswordRequestController,
);
router.post('/resetPassword', checkBan, validatePasswordReset, resetPasswordController);
module.exports = router; module.exports = router;

View file

@ -6,6 +6,7 @@ const { logger } = require('~/config');
const router = express.Router(); const router = express.Router();
const emailLoginEnabled = const emailLoginEnabled =
process.env.ALLOW_EMAIL_LOGIN === undefined || isEnabled(process.env.ALLOW_EMAIL_LOGIN); process.env.ALLOW_EMAIL_LOGIN === undefined || isEnabled(process.env.ALLOW_EMAIL_LOGIN);
const passwordResetEnabled = isEnabled(process.env.ALLOW_PASSWORD_RESET);
router.get('/', async function (req, res) { router.get('/', async function (req, res) {
const isBirthday = () => { const isBirthday = () => {
@ -42,6 +43,7 @@ router.get('/', async function (req, res) {
!!process.env.EMAIL_USERNAME && !!process.env.EMAIL_USERNAME &&
!!process.env.EMAIL_PASSWORD && !!process.env.EMAIL_PASSWORD &&
!!process.env.EMAIL_FROM, !!process.env.EMAIL_FROM,
passwordResetEnabled,
checkBalance: isEnabled(process.env.CHECK_BALANCE), checkBalance: isEnabled(process.env.CHECK_BALANCE),
showBirthdayIcon: showBirthdayIcon:
isBirthday() || isBirthday() ||

View file

@ -16,6 +16,19 @@ const domains = {
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
/**
* Check if email configuration is set
* @returns {Boolean}
*/
function checkEmailConfig() {
return (
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
!!process.env.EMAIL_USERNAME &&
!!process.env.EMAIL_PASSWORD &&
!!process.env.EMAIL_FROM
);
}
/** /**
* Logout user * Logout user
* *
@ -114,14 +127,20 @@ const registerUser = async (user) => {
/** /**
* Request password reset * Request password reset
* * @param {Express.Request} req
* @param {String} email
* @returns
*/ */
const requestPasswordReset = async (email) => { const requestPasswordReset = async (req) => {
const { email } = req.body;
const user = await User.findOne({ email }).lean(); const user = await User.findOne({ email }).lean();
const emailEnabled = checkEmailConfig();
logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`);
if (!user) { if (!user) {
return new Error('Email does not exist'); logger.warn(`[requestPasswordReset] [No user found] [Email: ${email}] [IP: ${req.ip}]`);
return {
message: 'If an account with that email exists, a password reset link has been sent to it.',
};
} }
let token = await Token.findOne({ userId: user._id }); let token = await Token.findOne({ userId: user._id });
@ -140,12 +159,6 @@ const requestPasswordReset = async (email) => {
const link = `${domains.client}/reset-password?token=${resetToken}&userId=${user._id}`; const link = `${domains.client}/reset-password?token=${resetToken}&userId=${user._id}`;
const emailEnabled =
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
!!process.env.EMAIL_USERNAME &&
!!process.env.EMAIL_PASSWORD &&
!!process.env.EMAIL_FROM;
if (emailEnabled) { if (emailEnabled) {
sendEmail( sendEmail(
user.email, user.email,
@ -158,10 +171,19 @@ const requestPasswordReset = async (email) => {
}, },
'requestPasswordReset.handlebars', 'requestPasswordReset.handlebars',
); );
return { link: '' }; logger.info(
`[requestPasswordReset] Link emailed. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
);
} else { } else {
logger.info(
`[requestPasswordReset] Link issued. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
);
return { link }; return { link };
} }
return {
message: 'If an account with that email exists, a password reset link has been sent to it.',
};
}; };
/** /**
@ -190,20 +212,23 @@ const resetPassword = async (userId, token, password) => {
await User.updateOne({ _id: userId }, { $set: { password: hash } }, { new: true }); await User.updateOne({ _id: userId }, { $set: { password: hash } }, { new: true });
const user = await User.findById({ _id: userId }); const user = await User.findById({ _id: userId });
const emailEnabled = checkEmailConfig();
sendEmail( if (emailEnabled) {
user.email, sendEmail(
'Password Reset Successfully', user.email,
{ 'Password Reset Successfully',
appName: process.env.APP_TITLE || 'LibreChat', {
name: user.name, appName: process.env.APP_TITLE || 'LibreChat',
year: new Date().getFullYear(), name: user.name,
}, year: new Date().getFullYear(),
'passwordReset.handlebars', },
); 'passwordReset.handlebars',
);
}
await passwordResetToken.deleteOne(); await passwordResetToken.deleteOne();
logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
return { message: 'Password reset was successful' }; return { message: 'Password reset was successful' };
}; };

View file

@ -3,6 +3,7 @@ const {
deprecatedAzureVariables, deprecatedAzureVariables,
conflictingAzureVariables, conflictingAzureVariables,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config'); const { logger } = require('~/config');
const secretDefaults = { const secretDefaults = {
@ -49,6 +50,8 @@ function checkVariables() {
Please use the config (\`librechat.yaml\`) file for setting up OpenRouter, and use \`OPENROUTER_KEY\` or another environment variable instead.`, Please use the config (\`librechat.yaml\`) file for setting up OpenRouter, and use \`OPENROUTER_KEY\` or another environment variable instead.`,
); );
} }
checkPasswordReset();
} }
/** /**
@ -107,4 +110,30 @@ Latest version: ${Constants.CONFIG_VERSION}
} }
} }
function checkPasswordReset() {
const emailEnabled =
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
!!process.env.EMAIL_USERNAME &&
!!process.env.EMAIL_PASSWORD &&
!!process.env.EMAIL_FROM;
const passwordResetAllowed = isEnabled(process.env.ALLOW_PASSWORD_RESET);
if (!emailEnabled && passwordResetAllowed) {
logger.warn(
`❗❗❗
Password reset is enabled with \`ALLOW_PASSWORD_RESET\` but email service is not configured.
This setup is insecure as password reset links will be issued with a recognized email.
Please configure email service for secure password reset functionality.
https://www.librechat.ai/docs/configuration/authentication/password_reset
`,
);
}
}
module.exports = { checkVariables, checkHealth, checkConfig, checkAzureVariables }; module.exports = { checkVariables, checkHealth, checkConfig, checkAzureVariables };

View file

@ -14,7 +14,9 @@ function Login() {
return ( return (
<> <>
{error && <ErrorMessage>{localize(getLoginError(error))}</ErrorMessage>} {error && <ErrorMessage>{localize(getLoginError(error))}</ErrorMessage>}
{startupConfig?.emailLoginEnabled && <LoginForm onSubmit={login} />} {startupConfig?.emailLoginEnabled && (
<LoginForm onSubmit={login} startupConfig={startupConfig} />
)}
{startupConfig?.registrationEnabled && ( {startupConfig?.registrationEnabled && (
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white"> <p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">
{' '} {' '}

View file

@ -1,19 +1,23 @@
import React from 'react'; import React from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import type { TLoginUser, TStartupConfig } from 'librechat-data-provider';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { TLoginUser } from 'librechat-data-provider';
type TLoginFormProps = { type TLoginFormProps = {
onSubmit: (data: TLoginUser) => void; onSubmit: (data: TLoginUser) => void;
startupConfig: TStartupConfig;
}; };
const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit }) => { const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig }) => {
const localize = useLocalize(); const localize = useLocalize();
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
} = useForm<TLoginUser>(); } = useForm<TLoginUser>();
if (!startupConfig) {
return null;
}
const renderError = (fieldName: string) => { const renderError = (fieldName: string) => {
const errorMessage = errors[fieldName]?.message; const errorMessage = errors[fieldName]?.message;
@ -81,9 +85,11 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit }) => {
</div> </div>
{renderError('password')} {renderError('password')}
</div> </div>
<a href="/forgot-password" className="text-sm text-green-500"> {startupConfig.passwordResetEnabled && (
{localize('com_auth_password_forgot')} <a href="/forgot-password" className="text-sm text-green-500">
</a> {localize('com_auth_password_forgot')}
</a>
)}
<div className="mt-6"> <div className="mt-6">
<button <button
aria-label="Sign in" aria-label="Sign in"

View file

@ -1,11 +1,37 @@
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useState, useEffect } from 'react'; import { useState, ReactNode } from 'react';
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query'; import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query';
import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider'; import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider';
import type { FC } from 'react';
import type { TLoginLayoutContext } from '~/common'; import type { TLoginLayoutContext } from '~/common';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
const BodyTextWrapper: FC<{ children: ReactNode }> = ({ children }) => {
return (
<div
className="relative mt-4 rounded border border-green-400 bg-green-100 px-4 py-3 text-green-700 dark:bg-green-900 dark:text-white"
role="alert"
>
{children}
</div>
);
};
const ResetPasswordBodyText = () => {
const localize = useLocalize();
return (
<div className="flex flex-col">
{localize('com_auth_reset_password_if_email_exists')}
<span>
<a className="text-sm text-green-500 hover:underline" href="/login">
{localize('com_auth_back_to_login')}
</a>
</span>
</div>
);
};
function RequestPasswordReset() { function RequestPasswordReset() {
const localize = useLocalize(); const localize = useLocalize();
const { const {
@ -13,72 +39,39 @@ function RequestPasswordReset() {
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
} = useForm<TRequestPasswordReset>(); } = useForm<TRequestPasswordReset>();
const [resetLink, setResetLink] = useState<string | undefined>(undefined); const [bodyText, setBodyText] = useState<ReactNode | undefined>(undefined);
const [bodyText, setBodyText] = useState<React.ReactNode | undefined>(undefined); const { startupConfig, setHeaderText } = useOutletContext<TLoginLayoutContext>();
const { startupConfig, setError, setHeaderText } = useOutletContext<TLoginLayoutContext>();
const requestPasswordReset = useRequestPasswordResetMutation(); const requestPasswordReset = useRequestPasswordResetMutation();
const onSubmit = (data: TRequestPasswordReset) => { const onSubmit = (data: TRequestPasswordReset) => {
requestPasswordReset.mutate(data, { requestPasswordReset.mutate(data, {
onSuccess: (data: TRequestPasswordResetResponse) => { onSuccess: (data: TRequestPasswordResetResponse) => {
if (!startupConfig?.emailEnabled) { if (data.link && !startupConfig?.emailEnabled) {
setResetLink(data.link); setHeaderText('com_auth_reset_password');
setBodyText(
<span>
{localize('com_auth_click')}{' '}
<a className="text-green-500 hover:underline" href={data.link}>
{localize('com_auth_here')}
</a>{' '}
{localize('com_auth_to_reset_your_password')}
</span>,
);
} else {
setHeaderText('com_auth_reset_password_link_sent');
setBodyText(<ResetPasswordBodyText />);
} }
}, },
onError: () => { onError: () => {
setError('com_auth_error_reset_password'); setHeaderText('com_auth_reset_password_link_sent');
setTimeout(() => { setBodyText(<ResetPasswordBodyText />);
setError(null);
}, 5000);
}, },
}); });
}; };
useEffect(() => {
if (bodyText) {
return;
}
if (!requestPasswordReset.isSuccess) {
setHeaderText('com_auth_reset_password');
setBodyText(undefined);
return;
}
if (startupConfig?.emailEnabled) {
setHeaderText('com_auth_reset_password_link_sent');
setBodyText(localize('com_auth_reset_password_email_sent'));
return;
}
setHeaderText('com_auth_reset_password');
setBodyText(
<span>
{localize('com_auth_click')}{' '}
<a className="text-green-500 hover:underline" href={resetLink}>
{localize('com_auth_here')}
</a>{' '}
{localize('com_auth_to_reset_your_password')}
</span>,
);
}, [
requestPasswordReset.isSuccess,
startupConfig?.emailEnabled,
resetLink,
localize,
setHeaderText,
bodyText,
]);
if (bodyText) { if (bodyText) {
return ( return <BodyTextWrapper>{bodyText}</BodyTextWrapper>;
<div
className="relative mt-4 rounded border border-green-400 bg-green-100 px-4 py-3 text-green-700 dark:bg-green-900 dark:text-white"
role="alert"
>
{bodyText}
</div>
);
} }
return ( return (

View file

@ -51,7 +51,7 @@ const setup = ({
user: {}, user: {},
}, },
}, },
useGetStartupCongfigReturnValue = mockStartupConfig, useGetStartupConfigReturnValue = mockStartupConfig,
} = {}) => { } = {}) => {
const mockUseLoginUser = jest const mockUseLoginUser = jest
.spyOn(mockDataProvider, 'useLoginUserMutation') .spyOn(mockDataProvider, 'useLoginUserMutation')
@ -64,18 +64,18 @@ const setup = ({
const mockUseGetStartupConfig = jest const mockUseGetStartupConfig = jest
.spyOn(mockDataProvider, 'useGetStartupConfig') .spyOn(mockDataProvider, 'useGetStartupConfig')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetStartupCongfigReturnValue); .mockReturnValue(useGetStartupConfigReturnValue);
const mockUseRefreshTokenMutation = jest const mockUseRefreshTokenMutation = jest
.spyOn(mockDataProvider, 'useRefreshTokenMutation') .spyOn(mockDataProvider, 'useRefreshTokenMutation')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useRefreshTokenMutationReturnValue); .mockReturnValue(useRefreshTokenMutationReturnValue);
const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({ const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({
startupConfig: useGetStartupCongfigReturnValue.data, startupConfig: useGetStartupConfigReturnValue.data,
}); });
const renderResult = render( const renderResult = render(
<AuthLayout <AuthLayout
startupConfig={useGetStartupCongfigReturnValue.data as TStartupConfig} startupConfig={useGetStartupConfigReturnValue.data as TStartupConfig}
isFetching={useGetStartupCongfigReturnValue.isFetching} isFetching={useGetStartupConfigReturnValue.isFetching}
error={null} error={null}
startupConfigError={null} startupConfigError={null}
header={'Welcome back'} header={'Welcome back'}
@ -161,7 +161,7 @@ test('Navigates to / on successful login', async () => {
isError: false, isError: false,
isSuccess: true, isSuccess: true,
}, },
useGetStartupCongfigReturnValue: { useGetStartupConfigReturnValue: {
...mockStartupConfig, ...mockStartupConfig,
data: { data: {
...mockStartupConfig.data, ...mockStartupConfig.data,

View file

@ -1,17 +1,103 @@
import { render } from 'test/layout-test-utils'; import { render } from 'test/layout-test-utils';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import * as mockDataProvider from 'librechat-data-provider/react-query';
import type { TStartupConfig } from 'librechat-data-provider';
import Login from '../LoginForm'; import Login from '../LoginForm';
jest.mock('librechat-data-provider/react-query');
const mockLogin = jest.fn(); const mockLogin = jest.fn();
const mockStartupConfig: TStartupConfig = {
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,
passwordResetEnabled: true,
serverDomain: 'mock-server',
appTitle: '',
ldapLoginEnabled: false,
emailEnabled: false,
checkBalance: false,
showBirthdayIcon: false,
helpAndFaqURL: '',
};
const setup = ({
useGetUserQueryReturnValue = {
isLoading: false,
isError: false,
data: {},
},
useLoginUserReturnValue = {
isLoading: false,
isError: false,
mutate: jest.fn(),
data: {},
isSuccess: false,
},
useRefreshTokenMutationReturnValue = {
isLoading: false,
isError: false,
mutate: jest.fn(),
data: {
token: 'mock-token',
user: {},
},
},
useGetStartupConfigReturnValue = {
isLoading: false,
isError: false,
data: mockStartupConfig,
},
} = {}) => {
const mockUseLoginUser = jest
.spyOn(mockDataProvider, 'useLoginUserMutation')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useLoginUserReturnValue);
const mockUseGetUserQuery = jest
.spyOn(mockDataProvider, 'useGetUserQuery')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetUserQueryReturnValue);
const mockUseGetStartupConfig = jest
.spyOn(mockDataProvider, 'useGetStartupConfig')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetStartupConfigReturnValue);
const mockUseRefreshTokenMutation = jest
.spyOn(mockDataProvider, 'useRefreshTokenMutation')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useRefreshTokenMutationReturnValue);
return {
mockUseLoginUser,
mockUseGetUserQuery,
mockUseGetStartupConfig,
mockUseRefreshTokenMutation,
};
};
beforeEach(() => {
setup();
});
test('renders login form', () => { test('renders login form', () => {
const { getByLabelText } = render(<Login onSubmit={mockLogin} />); const { getByLabelText } = render(
<Login onSubmit={mockLogin} startupConfig={mockStartupConfig} />,
);
expect(getByLabelText(/email/i)).toBeInTheDocument(); expect(getByLabelText(/email/i)).toBeInTheDocument();
expect(getByLabelText(/password/i)).toBeInTheDocument(); expect(getByLabelText(/password/i)).toBeInTheDocument();
}); });
test('submits login form', async () => { test('submits login form', async () => {
const { getByLabelText, getByRole } = render(<Login onSubmit={mockLogin} />); const { getByLabelText, getByRole } = render(
<Login onSubmit={mockLogin} startupConfig={mockStartupConfig} />,
);
const emailInput = getByLabelText(/email/i); const emailInput = getByLabelText(/email/i);
const passwordInput = getByLabelText(/password/i); const passwordInput = getByLabelText(/password/i);
const submitButton = getByRole('button', { name: /Sign in/i }); const submitButton = getByRole('button', { name: /Sign in/i });
@ -24,7 +110,9 @@ test('submits login form', async () => {
}); });
test('displays validation error messages', async () => { test('displays validation error messages', async () => {
const { getByLabelText, getByRole, getByText } = render(<Login onSubmit={mockLogin} />); const { getByLabelText, getByRole, getByText } = render(
<Login onSubmit={mockLogin} startupConfig={mockStartupConfig} />,
);
const emailInput = getByLabelText(/email/i); const emailInput = getByLabelText(/email/i);
const passwordInput = getByLabelText(/password/i); const passwordInput = getByLabelText(/password/i);
const submitButton = getByRole('button', { name: /Sign in/i }); const submitButton = getByRole('button', { name: /Sign in/i });

View file

@ -50,7 +50,7 @@ const setup = ({
user: {}, user: {},
}, },
}, },
useGetStartupCongfigReturnValue = mockStartupConfig, useGetStartupConfigReturnValue = mockStartupConfig,
} = {}) => { } = {}) => {
const mockUseRegisterUserMutation = jest const mockUseRegisterUserMutation = jest
.spyOn(mockDataProvider, 'useRegisterUserMutation') .spyOn(mockDataProvider, 'useRegisterUserMutation')
@ -63,18 +63,18 @@ const setup = ({
const mockUseGetStartupConfig = jest const mockUseGetStartupConfig = jest
.spyOn(mockDataProvider, 'useGetStartupConfig') .spyOn(mockDataProvider, 'useGetStartupConfig')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetStartupCongfigReturnValue); .mockReturnValue(useGetStartupConfigReturnValue);
const mockUseRefreshTokenMutation = jest const mockUseRefreshTokenMutation = jest
.spyOn(mockDataProvider, 'useRefreshTokenMutation') .spyOn(mockDataProvider, 'useRefreshTokenMutation')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useRefreshTokenMutationReturnValue); .mockReturnValue(useRefreshTokenMutationReturnValue);
const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({ const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({
startupConfig: useGetStartupCongfigReturnValue.data, startupConfig: useGetStartupConfigReturnValue.data,
}); });
const renderResult = render( const renderResult = render(
<AuthLayout <AuthLayout
startupConfig={useGetStartupCongfigReturnValue.data as TStartupConfig} startupConfig={useGetStartupConfigReturnValue.data as TStartupConfig}
isFetching={useGetStartupCongfigReturnValue.isFetching} isFetching={useGetStartupConfigReturnValue.isFetching}
error={null} error={null}
startupConfigError={null} startupConfigError={null}
header={'Create your account'} header={'Create your account'}

View file

@ -293,10 +293,10 @@ export default {
com_auth_here: 'HERE', com_auth_here: 'HERE',
com_auth_to_reset_your_password: 'to reset your password.', com_auth_to_reset_your_password: 'to reset your password.',
com_auth_reset_password_link_sent: 'Email Sent', com_auth_reset_password_link_sent: 'Email Sent',
com_auth_reset_password_if_email_exists:
'If an account with that email exists, an email with password reset instructions has been sent. Please make sure to check your spam folder.',
com_auth_reset_password_email_sent: com_auth_reset_password_email_sent:
'An email has been sent to you with further instructions to reset your password.', 'If the user is registered, an email will be sent to the inbox.',
com_auth_error_reset_password:
'There was a problem resetting your password. There was no user found with the email address provided. Please try again.',
com_auth_reset_password_success: 'Password Reset Success', com_auth_reset_password_success: 'Password Reset Success',
com_auth_login_with_new_password: 'You may now login with your new password.', com_auth_login_with_new_password: 'You may now login with your new password.',
com_auth_error_invalid_reset_token: 'This password reset token is no longer valid.', com_auth_error_invalid_reset_token: 'This password reset token is no longer valid.',

View file

@ -276,6 +276,7 @@ export type TStartupConfig = {
emailLoginEnabled: boolean; emailLoginEnabled: boolean;
registrationEnabled: boolean; registrationEnabled: boolean;
socialLoginEnabled: boolean; socialLoginEnabled: boolean;
passwordResetEnabled: boolean;
emailEnabled: boolean; emailEnabled: boolean;
checkBalance: boolean; checkBalance: boolean;
showBirthdayIcon: boolean; showBirthdayIcon: boolean;