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_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
|
||||||
|
|
|
||||||
33
.eslintrc.js
33
.eslintrc.js
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
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.
|
- **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) => {
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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) {
|
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.');
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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() ||
|
||||||
|
|
|
||||||
|
|
@ -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' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
{' '}
|
{' '}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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'}
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue