mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🔒 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:
parent
a7f5b57272
commit
5452d4c20c
20 changed files with 288 additions and 137 deletions
|
|
@ -319,6 +319,7 @@ ALLOW_EMAIL_LOGIN=true
|
|||
ALLOW_REGISTRATION=true
|
||||
ALLOW_SOCIAL_LOGIN=false
|
||||
ALLOW_SOCIAL_REGISTRATION=false
|
||||
ALLOW_PASSWORD_RESET=false
|
||||
# ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out
|
||||
|
||||
SESSION_EXPIRY=1000 * 60 * 15
|
||||
|
|
|
|||
33
.eslintrc.js
33
.eslintrc.js
|
|
@ -67,39 +67,6 @@ module.exports = {
|
|||
'react/display-name': ['off'],
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^_' }],
|
||||
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: [
|
||||
{
|
||||
|
|
|
|||
12
.github/CONTRIBUTING.md
vendored
12
.github/CONTRIBUTING.md
vendored
|
|
@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ const getUserController = async (req, res) => {
|
|||
|
||||
const resetPasswordRequestController = async (req, res) => {
|
||||
try {
|
||||
const resetService = await requestPasswordReset(req.body.email);
|
||||
const resetService = await requestPasswordReset(req);
|
||||
if (resetService instanceof Error) {
|
||||
return res.status(400).json(resetService);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const validateMessageReq = require('./validateMessageReq');
|
|||
const buildEndpointOption = require('./buildEndpointOption');
|
||||
const validateRegistration = require('./validateRegistration');
|
||||
const validateImageRequest = require('./validateImageRequest');
|
||||
const validatePasswordReset = require('./validatePasswordReset');
|
||||
const moderateText = require('./moderateText');
|
||||
const noIndex = require('./noIndex');
|
||||
const importLimiters = require('./importLimiters');
|
||||
|
|
@ -40,6 +41,7 @@ module.exports = {
|
|||
buildEndpointOption,
|
||||
validateRegistration,
|
||||
validateImageRequest,
|
||||
validatePasswordReset,
|
||||
validateModel,
|
||||
moderateText,
|
||||
noIndex,
|
||||
|
|
|
|||
13
api/server/middleware/validatePasswordReset.js
Normal file
13
api/server/middleware/validatePasswordReset.js
Normal 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;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
const { isEnabled } = require('~/server/utils');
|
||||
|
||||
function validateRegistration(req, res, next) {
|
||||
const setting = process.env.ALLOW_REGISTRATION?.toLowerCase();
|
||||
if (setting === 'true') {
|
||||
if (isEnabled(process.env.ALLOW_REGISTRATION)) {
|
||||
next();
|
||||
} else {
|
||||
res.status(403).send('Registration is not allowed.');
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ afterEach(() => {
|
|||
delete process.env.DOMAIN_SERVER;
|
||||
delete process.env.ALLOW_REGISTRATION;
|
||||
delete process.env.ALLOW_SOCIAL_LOGIN;
|
||||
delete process.env.ALLOW_PASSWORD_RESET;
|
||||
delete process.env.LDAP_URL;
|
||||
delete process.env.LDAP_BIND_DN;
|
||||
delete process.env.LDAP_BIND_CREDENTIALS;
|
||||
|
|
@ -55,6 +56,7 @@ describe.skip('GET /', () => {
|
|||
process.env.DOMAIN_SERVER = 'http://test-server.com';
|
||||
process.env.ALLOW_REGISTRATION = 'true';
|
||||
process.env.ALLOW_SOCIAL_LOGIN = 'true';
|
||||
process.env.ALLOW_PASSWORD_RESET = 'true';
|
||||
process.env.LDAP_URL = 'Test LDAP URL';
|
||||
process.env.LDAP_BIND_DN = 'Test LDAP Bind DN';
|
||||
process.env.LDAP_BIND_CREDENTIALS = 'Test LDAP Bind Credentials';
|
||||
|
|
@ -78,6 +80,7 @@ describe.skip('GET /', () => {
|
|||
serverDomain: 'http://test-server.com',
|
||||
emailLoginEnabled: 'true',
|
||||
registrationEnabled: 'true',
|
||||
passwordResetEnabled: 'true',
|
||||
socialLoginEnabled: 'true',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const {
|
|||
requireLdapAuth,
|
||||
requireLocalAuth,
|
||||
validateRegistration,
|
||||
validatePasswordReset,
|
||||
} = require('../middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -32,7 +33,12 @@ router.post(
|
|||
);
|
||||
router.post('/refresh', refreshController);
|
||||
router.post('/register', registerLimiter, checkBan, validateRegistration, registrationController);
|
||||
router.post('/requestPasswordReset', resetPasswordRequestController);
|
||||
router.post('/resetPassword', resetPasswordController);
|
||||
router.post(
|
||||
'/requestPasswordReset',
|
||||
checkBan,
|
||||
validatePasswordReset,
|
||||
resetPasswordRequestController,
|
||||
);
|
||||
router.post('/resetPassword', checkBan, validatePasswordReset, resetPasswordController);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const { logger } = require('~/config');
|
|||
const router = express.Router();
|
||||
const emailLoginEnabled =
|
||||
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) {
|
||||
const isBirthday = () => {
|
||||
|
|
@ -42,6 +43,7 @@ router.get('/', async function (req, res) {
|
|||
!!process.env.EMAIL_USERNAME &&
|
||||
!!process.env.EMAIL_PASSWORD &&
|
||||
!!process.env.EMAIL_FROM,
|
||||
passwordResetEnabled,
|
||||
checkBalance: isEnabled(process.env.CHECK_BALANCE),
|
||||
showBirthdayIcon:
|
||||
isBirthday() ||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,19 @@ const domains = {
|
|||
|
||||
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
|
||||
*
|
||||
|
|
@ -114,14 +127,20 @@ const registerUser = async (user) => {
|
|||
|
||||
/**
|
||||
* Request password reset
|
||||
*
|
||||
* @param {String} email
|
||||
* @returns
|
||||
* @param {Express.Request} req
|
||||
*/
|
||||
const requestPasswordReset = async (email) => {
|
||||
const requestPasswordReset = async (req) => {
|
||||
const { email } = req.body;
|
||||
const user = await User.findOne({ email }).lean();
|
||||
const emailEnabled = checkEmailConfig();
|
||||
|
||||
logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`);
|
||||
|
||||
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 });
|
||||
|
|
@ -140,12 +159,6 @@ const requestPasswordReset = async (email) => {
|
|||
|
||||
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) {
|
||||
sendEmail(
|
||||
user.email,
|
||||
|
|
@ -158,10 +171,19 @@ const requestPasswordReset = async (email) => {
|
|||
},
|
||||
'requestPasswordReset.handlebars',
|
||||
);
|
||||
return { link: '' };
|
||||
logger.info(
|
||||
`[requestPasswordReset] Link emailed. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[requestPasswordReset] Link issued. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
|
||||
);
|
||||
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 });
|
||||
|
||||
const user = await User.findById({ _id: userId });
|
||||
const emailEnabled = checkEmailConfig();
|
||||
|
||||
sendEmail(
|
||||
user.email,
|
||||
'Password Reset Successfully',
|
||||
{
|
||||
appName: process.env.APP_TITLE || 'LibreChat',
|
||||
name: user.name,
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
'passwordReset.handlebars',
|
||||
);
|
||||
if (emailEnabled) {
|
||||
sendEmail(
|
||||
user.email,
|
||||
'Password Reset Successfully',
|
||||
{
|
||||
appName: process.env.APP_TITLE || 'LibreChat',
|
||||
name: user.name,
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
'passwordReset.handlebars',
|
||||
);
|
||||
}
|
||||
|
||||
await passwordResetToken.deleteOne();
|
||||
|
||||
logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
|
||||
return { message: 'Password reset was successful' };
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const {
|
|||
deprecatedAzureVariables,
|
||||
conflictingAzureVariables,
|
||||
} = require('librechat-data-provider');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ function Login() {
|
|||
return (
|
||||
<>
|
||||
{error && <ErrorMessage>{localize(getLoginError(error))}</ErrorMessage>}
|
||||
{startupConfig?.emailLoginEnabled && <LoginForm onSubmit={login} />}
|
||||
{startupConfig?.emailLoginEnabled && (
|
||||
<LoginForm onSubmit={login} startupConfig={startupConfig} />
|
||||
)}
|
||||
{startupConfig?.registrationEnabled && (
|
||||
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">
|
||||
{' '}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { TLoginUser, TStartupConfig } from 'librechat-data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { TLoginUser } from 'librechat-data-provider';
|
||||
|
||||
type TLoginFormProps = {
|
||||
onSubmit: (data: TLoginUser) => void;
|
||||
startupConfig: TStartupConfig;
|
||||
};
|
||||
|
||||
const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit }) => {
|
||||
const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig }) => {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<TLoginUser>();
|
||||
if (!startupConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderError = (fieldName: string) => {
|
||||
const errorMessage = errors[fieldName]?.message;
|
||||
|
|
@ -81,9 +85,11 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit }) => {
|
|||
</div>
|
||||
{renderError('password')}
|
||||
</div>
|
||||
<a href="/forgot-password" className="text-sm text-green-500">
|
||||
{localize('com_auth_password_forgot')}
|
||||
</a>
|
||||
{startupConfig.passwordResetEnabled && (
|
||||
<a href="/forgot-password" className="text-sm text-green-500">
|
||||
{localize('com_auth_password_forgot')}
|
||||
</a>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
aria-label="Sign in"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,37 @@
|
|||
import { useForm } from 'react-hook-form';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, ReactNode } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider';
|
||||
import type { FC } from 'react';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
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() {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
|
|
@ -13,72 +39,39 @@ function RequestPasswordReset() {
|
|||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<TRequestPasswordReset>();
|
||||
const [resetLink, setResetLink] = useState<string | undefined>(undefined);
|
||||
const [bodyText, setBodyText] = useState<React.ReactNode | undefined>(undefined);
|
||||
const { startupConfig, setError, setHeaderText } = useOutletContext<TLoginLayoutContext>();
|
||||
const [bodyText, setBodyText] = useState<ReactNode | undefined>(undefined);
|
||||
const { startupConfig, setHeaderText } = useOutletContext<TLoginLayoutContext>();
|
||||
|
||||
const requestPasswordReset = useRequestPasswordResetMutation();
|
||||
|
||||
const onSubmit = (data: TRequestPasswordReset) => {
|
||||
requestPasswordReset.mutate(data, {
|
||||
onSuccess: (data: TRequestPasswordResetResponse) => {
|
||||
if (!startupConfig?.emailEnabled) {
|
||||
setResetLink(data.link);
|
||||
if (data.link && !startupConfig?.emailEnabled) {
|
||||
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: () => {
|
||||
setError('com_auth_error_reset_password');
|
||||
setTimeout(() => {
|
||||
setError(null);
|
||||
}, 5000);
|
||||
setHeaderText('com_auth_reset_password_link_sent');
|
||||
setBodyText(<ResetPasswordBodyText />);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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) {
|
||||
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"
|
||||
>
|
||||
{bodyText}
|
||||
</div>
|
||||
);
|
||||
return <BodyTextWrapper>{bodyText}</BodyTextWrapper>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const setup = ({
|
|||
user: {},
|
||||
},
|
||||
},
|
||||
useGetStartupCongfigReturnValue = mockStartupConfig,
|
||||
useGetStartupConfigReturnValue = mockStartupConfig,
|
||||
} = {}) => {
|
||||
const mockUseLoginUser = jest
|
||||
.spyOn(mockDataProvider, 'useLoginUserMutation')
|
||||
|
|
@ -64,18 +64,18 @@ const setup = ({
|
|||
const mockUseGetStartupConfig = jest
|
||||
.spyOn(mockDataProvider, 'useGetStartupConfig')
|
||||
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
|
||||
.mockReturnValue(useGetStartupCongfigReturnValue);
|
||||
.mockReturnValue(useGetStartupConfigReturnValue);
|
||||
const mockUseRefreshTokenMutation = jest
|
||||
.spyOn(mockDataProvider, 'useRefreshTokenMutation')
|
||||
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
|
||||
.mockReturnValue(useRefreshTokenMutationReturnValue);
|
||||
const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({
|
||||
startupConfig: useGetStartupCongfigReturnValue.data,
|
||||
startupConfig: useGetStartupConfigReturnValue.data,
|
||||
});
|
||||
const renderResult = render(
|
||||
<AuthLayout
|
||||
startupConfig={useGetStartupCongfigReturnValue.data as TStartupConfig}
|
||||
isFetching={useGetStartupCongfigReturnValue.isFetching}
|
||||
startupConfig={useGetStartupConfigReturnValue.data as TStartupConfig}
|
||||
isFetching={useGetStartupConfigReturnValue.isFetching}
|
||||
error={null}
|
||||
startupConfigError={null}
|
||||
header={'Welcome back'}
|
||||
|
|
@ -161,7 +161,7 @@ test('Navigates to / on successful login', async () => {
|
|||
isError: false,
|
||||
isSuccess: true,
|
||||
},
|
||||
useGetStartupCongfigReturnValue: {
|
||||
useGetStartupConfigReturnValue: {
|
||||
...mockStartupConfig,
|
||||
data: {
|
||||
...mockStartupConfig.data,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,103 @@
|
|||
import { render } from 'test/layout-test-utils';
|
||||
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';
|
||||
|
||||
jest.mock('librechat-data-provider/react-query');
|
||||
|
||||
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', () => {
|
||||
const { getByLabelText } = render(<Login onSubmit={mockLogin} />);
|
||||
const { getByLabelText } = render(
|
||||
<Login onSubmit={mockLogin} startupConfig={mockStartupConfig} />,
|
||||
);
|
||||
expect(getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(getByLabelText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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 passwordInput = getByLabelText(/password/i);
|
||||
const submitButton = getByRole('button', { name: /Sign in/i });
|
||||
|
|
@ -24,7 +110,9 @@ test('submits login form', 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 passwordInput = getByLabelText(/password/i);
|
||||
const submitButton = getByRole('button', { name: /Sign in/i });
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const setup = ({
|
|||
user: {},
|
||||
},
|
||||
},
|
||||
useGetStartupCongfigReturnValue = mockStartupConfig,
|
||||
useGetStartupConfigReturnValue = mockStartupConfig,
|
||||
} = {}) => {
|
||||
const mockUseRegisterUserMutation = jest
|
||||
.spyOn(mockDataProvider, 'useRegisterUserMutation')
|
||||
|
|
@ -63,18 +63,18 @@ const setup = ({
|
|||
const mockUseGetStartupConfig = jest
|
||||
.spyOn(mockDataProvider, 'useGetStartupConfig')
|
||||
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
|
||||
.mockReturnValue(useGetStartupCongfigReturnValue);
|
||||
.mockReturnValue(useGetStartupConfigReturnValue);
|
||||
const mockUseRefreshTokenMutation = jest
|
||||
.spyOn(mockDataProvider, 'useRefreshTokenMutation')
|
||||
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
|
||||
.mockReturnValue(useRefreshTokenMutationReturnValue);
|
||||
const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({
|
||||
startupConfig: useGetStartupCongfigReturnValue.data,
|
||||
startupConfig: useGetStartupConfigReturnValue.data,
|
||||
});
|
||||
const renderResult = render(
|
||||
<AuthLayout
|
||||
startupConfig={useGetStartupCongfigReturnValue.data as TStartupConfig}
|
||||
isFetching={useGetStartupCongfigReturnValue.isFetching}
|
||||
startupConfig={useGetStartupConfigReturnValue.data as TStartupConfig}
|
||||
isFetching={useGetStartupConfigReturnValue.isFetching}
|
||||
error={null}
|
||||
startupConfigError={null}
|
||||
header={'Create your account'}
|
||||
|
|
|
|||
|
|
@ -293,10 +293,10 @@ export default {
|
|||
com_auth_here: 'HERE',
|
||||
com_auth_to_reset_your_password: 'to reset your password.',
|
||||
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:
|
||||
'An email has been sent to you with further instructions to reset your password.',
|
||||
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.',
|
||||
'If the user is registered, an email will be sent to the inbox.',
|
||||
com_auth_reset_password_success: 'Password Reset Success',
|
||||
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.',
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ export type TStartupConfig = {
|
|||
emailLoginEnabled: boolean;
|
||||
registrationEnabled: boolean;
|
||||
socialLoginEnabled: boolean;
|
||||
passwordResetEnabled: boolean;
|
||||
emailEnabled: boolean;
|
||||
checkBalance: boolean;
|
||||
showBirthdayIcon: boolean;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue