📧 feat: email verification (#2344)

* feat: verification email

* chore: email verification invalid; localize: update

* fix: redirect to login when signup: fix: save emailVerified correctly

* docs: update ALLOW_UNVERIFIED_EMAIL_LOGIN; fix: don't accept login only when ALLOW_UNVERIFIED_EMAIL_LOGIN = true

* fix: user needs to be authenticated

* style: update

* fix: registration success message and redirect logic

* refactor: use `isEnabled` in ALLOW_UNVERIFIED_EMAIL_LOGIN

* refactor: move checkEmailConfig to server/utils

* refactor: use req as param for verifyEmail function

* chore: jsdoc

* chore: remove console log

* refactor: rename `createNewUser` to `createSocialUser`

* refactor: update typing and add expiresAt field to userSchema

* refactor: begin use of user methods over direct model access for User

* refactor: initial email verification rewrite

* chore: typing

* refactor: registration flow rewrite

* chore: remove help center text

* refactor: update getUser to getUserById and add findUser methods. general fixes from recent changes

* refactor: Update updateUser method to remove expiresAt field and use $set and $unset operations, createUser now returns Id only

* refactor: Update openidStrategy to use optional chaining for avatar check, move saveBuffer init to buffer condition

* refactor: logout on deleteUser mutatation

* refactor: Update openidStrategy login success message format

* refactor: Add emailVerified field to Discord and Facebook profile details

* refactor: move limiters to separate middleware dir

* refactor: Add limiters for email verification and password reset

* refactor: Remove getUserController and update routes and controllers accordingly

* refactor: Update getUserById method to exclude password and version fields

* refactor: move verification to user route, add resend verification option

* refactor: Improve email verification process and resend option

* refactor: remove more direct model access of User and remove unused code

* refactor: replace user authentication methods and token generation

* fix: add user.id to jwt user

* refactor: Update AuthContext to include setError function, add resend link to Login Form, make registration redirect shorter

* fix(updateUserPluginsService): ensure userPlugins variable is defined

* refactor: Delete all shared links for a specific user

* fix: remove use of direct User.save() in handleExistingUser

* fix(importLibreChatConvo): handle missing createdAt field in messages

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2024-06-07 21:06:47 +02:00 committed by GitHub
parent b7fef6958b
commit ee673d682e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 1863 additions and 1117 deletions

View file

@ -321,6 +321,7 @@ ALLOW_SOCIAL_LOGIN=false
ALLOW_SOCIAL_REGISTRATION=false
ALLOW_PASSWORD_RESET=false
# ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out
ALLOW_UNVERIFIED_EMAIL_LOGIN=true
SESSION_EXPIRY=1000 * 60 * 15
REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7

View file

@ -63,6 +63,10 @@ const namespaces = {
[ViolationTypes.TTS_LIMIT]: createViolationInstance(ViolationTypes.TTS_LIMIT),
[ViolationTypes.STT_LIMIT]: createViolationInstance(ViolationTypes.STT_LIMIT),
[ViolationTypes.FILE_UPLOAD_LIMIT]: createViolationInstance(ViolationTypes.FILE_UPLOAD_LIMIT),
[ViolationTypes.VERIFY_EMAIL_LIMIT]: createViolationInstance(ViolationTypes.VERIFY_EMAIL_LIMIT),
[ViolationTypes.RESET_PASSWORD_LIMIT]: createViolationInstance(
ViolationTypes.RESET_PASSWORD_LIMIT,
),
[ViolationTypes.ILLEGAL_MODEL_REQUEST]: createViolationInstance(
ViolationTypes.ILLEGAL_MODEL_REQUEST,
),

View file

@ -1,6 +1,6 @@
const { isEnabled } = require('~/server/utils');
const getLogStores = require('./getLogStores');
const banViolation = require('./banViolation');
const { isEnabled } = require('../server/utils');
/**
* Logs the violation.

View file

@ -86,4 +86,21 @@ module.exports = {
}
return await SharedLink.findOneAndDelete({ shareId, user });
},
/**
* Deletes all shared links for a specific user.
* @param {string} user - The user ID.
* @returns {Promise<{ message: string, deletedCount?: number }>} A result object indicating success or error message.
*/
deleteAllSharedLinks: async (user) => {
try {
const result = await SharedLink.deleteMany({ user });
return {
message: 'All shared links have been deleted successfully',
deletedCount: result.deletedCount,
};
} catch (error) {
logger.error('[deleteAllSharedLinks] Error deleting shared links', error);
return { message: 'Error deleting shared links' };
}
},
};

View file

@ -1,61 +1,5 @@
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const signPayload = require('../server/services/signPayload');
const userSchema = require('./schema/userSchema.js');
const { SESSION_EXPIRY } = process.env ?? {};
const expires = eval(SESSION_EXPIRY) ?? 1000 * 60 * 15;
userSchema.methods.toJSON = function () {
return {
id: this._id,
provider: this.provider,
email: this.email,
name: this.name,
username: this.username,
avatar: this.avatar,
role: this.role,
emailVerified: this.emailVerified,
plugins: this.plugins,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
};
userSchema.methods.generateToken = async function () {
return await signPayload({
payload: {
id: this._id,
username: this.username,
provider: this.provider,
email: this.email,
},
secret: process.env.JWT_SECRET,
expirationTime: expires / 1000,
});
};
userSchema.methods.comparePassword = function (candidatePassword, callback) {
bcrypt.compare(candidatePassword, this.password, (err, isMatch) => {
if (err) {
return callback(err);
}
callback(null, isMatch);
});
};
module.exports.hashPassword = async (password) => {
const hashedPassword = await new Promise((resolve, reject) => {
bcrypt.hash(password, 10, function (err, hash) {
if (err) {
reject(err);
} else {
resolve(hash);
}
});
});
return hashedPassword;
};
const userSchema = require('~/models/schema/userSchema');
const User = mongoose.model('User', userSchema);

View file

@ -6,9 +6,18 @@ const {
deleteMessagesSince,
deleteMessages,
} = require('./Message');
const {
comparePassword,
deleteUserById,
generateToken,
getUserById,
updateUser,
createUser,
countUsers,
findUser,
} = require('./userMethods');
const { getConvoTitle, getConvo, saveConvo, deleteConvos } = require('./Conversation');
const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset');
const { hashPassword, getUser, updateUser } = require('./userMethods');
const {
findFileById,
createFile,
@ -29,9 +38,14 @@ module.exports = {
Session,
Balance,
hashPassword,
comparePassword,
deleteUserById,
generateToken,
getUserById,
countUsers,
createUser,
updateUser,
getUser,
findUser,
getMessages,
saveMessage,

View file

@ -3,9 +3,9 @@ const mongoose = require('mongoose');
/**
* @typedef {Object} MongoFile
* @property {mongoose.Schema.Types.ObjectId} [_id] - MongoDB Document ID
* @property {ObjectId} [_id] - MongoDB Document ID
* @property {number} [__v] - MongoDB Version Key
* @property {mongoose.Schema.Types.ObjectId} user - User ID
* @property {ObjectId} user - User ID
* @property {string} [conversationId] - Optional conversation ID
* @property {string} file_id - File identifier
* @property {string} [temp_file_id] - Temporary File identifier
@ -14,17 +14,19 @@ const mongoose = require('mongoose');
* @property {string} filepath - Location of the file
* @property {'file'} object - Type of object, always 'file'
* @property {string} type - Type of file
* @property {number} usage - Number of uses of the file
* @property {number} [usage=0] - Number of uses of the file
* @property {string} [context] - Context of the file origin
* @property {boolean} [embedded] - Whether or not the file is embedded in vector db
* @property {boolean} [embedded=false] - Whether or not the file is embedded in vector db
* @property {string} [model] - The model to identify the group region of the file (for Azure OpenAI hosting)
* @property {string} [source] - The source of the file
* @property {string} [source] - The source of the file (e.g., from FileSources)
* @property {number} [width] - Optional width of the file
* @property {number} [height] - Optional height of the file
* @property {Date} [expiresAt] - Optional height of the file
* @property {Date} [expiresAt] - Optional expiration date of the file
* @property {Date} [createdAt] - Date when the file was created
* @property {Date} [updatedAt] - Date when the file was updated
*/
/** @type {MongooseSchema<MongoFile>} */
const fileSchema = mongoose.Schema(
{
user: {
@ -91,7 +93,7 @@ const fileSchema = mongoose.Schema(
height: Number,
expiresAt: {
type: Date,
expires: 3600,
expires: 3600, // 1 hour in seconds
},
},
{

View file

@ -7,6 +7,9 @@ const tokenSchema = new Schema({
required: true,
ref: 'user',
},
email: {
type: String,
},
token: {
type: String,
required: true,

View file

@ -1,5 +1,35 @@
const mongoose = require('mongoose');
/**
* @typedef {Object} MongoSession
* @property {string} [refreshToken] - The refresh token
*/
/**
* @typedef {Object} MongoUser
* @property {ObjectId} [_id] - MongoDB Document ID
* @property {string} [name] - The user's name
* @property {string} [username] - The user's username, in lowercase
* @property {string} email - The user's email address
* @property {boolean} emailVerified - Whether the user's email is verified
* @property {string} [password] - The user's password, trimmed with 8-128 characters
* @property {string} [avatar] - The URL of the user's avatar
* @property {string} provider - The provider of the user's account (e.g., 'local', 'google')
* @property {string} [role='USER'] - The role of the user
* @property {string} [googleId] - Optional Google ID for the user
* @property {string} [facebookId] - Optional Facebook ID for the user
* @property {string} [openidId] - Optional OpenID ID for the user
* @property {string} [ldapId] - Optional LDAP ID for the user
* @property {string} [githubId] - Optional GitHub ID for the user
* @property {string} [discordId] - Optional Discord ID for the user
* @property {Array} [plugins=[]] - List of plugins used by the user
* @property {Array.<MongoSession>} [refreshToken] - List of sessions with refresh tokens
* @property {Date} [expiresAt] - Optional expiration date of the file
* @property {Date} [createdAt] - Date when the user was created (added by timestamps)
* @property {Date} [updatedAt] - Date when the user was last updated (added by timestamps)
*/
/** @type {MongooseSchema<MongoSession>} */
const Session = mongoose.Schema({
refreshToken: {
type: String,
@ -7,6 +37,7 @@ const Session = mongoose.Schema({
},
});
/** @type {MongooseSchema<MongoUser>} */
const userSchema = mongoose.Schema(
{
name: {
@ -86,6 +117,10 @@ const userSchema = mongoose.Schema(
refreshToken: {
type: [Session],
},
expiresAt: {
type: Date,
expires: 604800, // 7 days in seconds
},
},
{ timestamps: true },
);

View file

@ -1,28 +1,37 @@
const bcrypt = require('bcryptjs');
const signPayload = require('~/server/services/signPayload');
const User = require('./User');
const hashPassword = async (password) => {
const hashedPassword = await new Promise((resolve, reject) => {
bcrypt.hash(password, 10, function (err, hash) {
if (err) {
reject(err);
} else {
resolve(hash);
}
});
});
return hashedPassword;
};
/**
* Retrieve a user by ID and convert the found user document to a plain object.
*
* @param {string} userId - The ID of the user to find and return as a plain object.
* @returns {Promise<Object>} A plain object representing the user document, or `null` if no user is found.
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
* @returns {Promise<MongoUser>} A plain object representing the user document, or `null` if no user is found.
*/
const getUser = async function (userId) {
return await User.findById(userId).lean();
const getUserById = async function (userId, fieldsToSelect = null) {
const query = User.findById(userId);
if (fieldsToSelect) {
query.select(fieldsToSelect);
}
return await query.lean();
};
/**
* Search for a single user based on partial data and return matching user document as plain object.
* @param {Partial<MongoUser>} searchCriteria - The partial data to use for searching the user.
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
* @returns {Promise<MongoUser>} A plain object representing the user document, or `null` if no user is found.
*/
const findUser = async function (searchCriteria, fieldsToSelect = null) {
const query = User.findOne(searchCriteria);
if (fieldsToSelect) {
query.select(fieldsToSelect);
}
return await query.lean();
};
/**
@ -30,17 +39,132 @@ const getUser = async function (userId) {
*
* @param {string} userId - The ID of the user to update.
* @param {Object} updateData - An object containing the properties to update.
* @returns {Promise<Object>} The updated user document as a plain object, or `null` if no user is found.
* @returns {Promise<MongoUser>} The updated user document as a plain object, or `null` if no user is found.
*/
const updateUser = async function (userId, updateData) {
return await User.findByIdAndUpdate(userId, updateData, {
const updateOperation = {
$set: updateData,
$unset: { expiresAt: '' }, // Remove the expiresAt field to prevent TTL
};
return await User.findByIdAndUpdate(userId, updateOperation, {
new: true,
runValidators: true,
}).lean();
};
module.exports = {
hashPassword,
updateUser,
getUser,
/**
* Creates a new user, optionally with a TTL of 1 week.
* @param {MongoUser} data - The user data to be created, must contain user_id.
* @param {boolean} [disableTTL=true] - Whether to disable the TTL. Defaults to `true`.
* @returns {Promise<string>} A promise that resolves to the created user document ID.
* @throws {Error} If a user with the same user_id already exists.
*/
const createUser = async (data, disableTTL = true) => {
const userData = {
...data,
expiresAt: new Date(Date.now() + 604800 * 1000), // 1 week in milliseconds
};
if (disableTTL) {
delete userData.expiresAt;
}
try {
const result = await User.collection.insertOne(userData);
return result.insertedId;
} catch (error) {
if (error.code === 11000) {
// Duplicate key error code
throw new Error(`User with \`_id\` ${data._id} already exists.`);
} else {
throw error;
}
}
};
/**
* Count the number of user documents in the collection based on the provided filter.
*
* @param {Object} [filter={}] - The filter to apply when counting the documents.
* @returns {Promise<number>} The count of documents that match the filter.
*/
const countUsers = async function (filter = {}) {
return await User.countDocuments(filter);
};
/**
* Delete a user by their unique ID.
*
* @param {string} userId - The ID of the user to delete.
* @returns {Promise<{ deletedCount: number }>} An object indicating the number of deleted documents.
*/
const deleteUserById = async function (userId) {
try {
const result = await User.deleteOne({ _id: userId });
if (result.deletedCount === 0) {
return { deletedCount: 0, message: 'No user found with that ID.' };
}
return { deletedCount: result.deletedCount, message: 'User was deleted successfully.' };
} catch (error) {
throw new Error('Error deleting user: ' + error.message);
}
};
const { SESSION_EXPIRY } = process.env ?? {};
const expires = eval(SESSION_EXPIRY) ?? 1000 * 60 * 15;
/**
* Generates a JWT token for a given user.
*
* @param {MongoUser} user - ID of the user for whom the token is being generated.
* @returns {Promise<string>} A promise that resolves to a JWT token.
*/
const generateToken = async (user) => {
if (!user) {
throw new Error('No user provided');
}
return await signPayload({
payload: {
id: user._id,
username: user.username,
provider: user.provider,
email: user.email,
},
secret: process.env.JWT_SECRET,
expirationTime: expires / 1000,
});
};
/**
* Compares the provided password with the user's password.
*
* @param {MongoUser} user - the user to compare password for.
* @param {string} candidatePassword - The password to test against the user's password.
* @returns {Promise<boolean>} A promise that resolves to a boolean indicating if the password matches.
*/
const comparePassword = async (user, candidatePassword) => {
if (!user) {
throw new Error('No user provided');
}
return new Promise((resolve, reject) => {
bcrypt.compare(candidatePassword, user.password, (err, isMatch) => {
if (err) {
reject(err);
}
resolve(isMatch);
});
});
};
module.exports = {
comparePassword,
deleteUserById,
generateToken,
getUserById,
countUsers,
createUser,
updateUser,
findUser,
};

View file

@ -1,42 +1,26 @@
const crypto = require('crypto');
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const { Session, User } = require('~/models');
const {
registerUser,
resetPassword,
setAuthTokens,
requestPasswordReset,
} = require('~/server/services/AuthService');
const { Session, getUserById } = require('~/models');
const { logger } = require('~/config');
const registrationController = async (req, res) => {
try {
const response = await registerUser(req.body);
if (response.status === 200) {
const { status, user } = response;
let newUser = await User.findOne({ _id: user._id });
if (!newUser) {
newUser = new User(user);
await newUser.save();
}
const token = await setAuthTokens(user._id, res);
res.setHeader('Authorization', `Bearer ${token}`);
res.status(status).send({ user });
} else {
const { status, message } = response;
res.status(status).send({ message });
}
const { status, message } = response;
res.status(status).send({ message });
} catch (err) {
logger.error('[registrationController]', err);
return res.status(500).json({ message: err.message });
}
};
const getUserController = async (req, res) => {
return res.status(200).send(req.user);
};
const resetPasswordRequestController = async (req, res) => {
try {
const resetService = await requestPasswordReset(req);
@ -77,7 +61,7 @@ const refreshController = async (req, res) => {
try {
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const user = await User.findOne({ _id: payload.id });
const user = await getUserById(payload.id, '-password -__v');
if (!user) {
return res.status(401).redirect('/login');
}
@ -86,8 +70,7 @@ const refreshController = async (req, res) => {
if (process.env.NODE_ENV === 'CI') {
const token = await setAuthTokens(userId, res);
const userObj = user.toJSON();
return res.status(200).send({ token, user: userObj });
return res.status(200).send({ token, user });
}
// Hash the refresh token
@ -98,8 +81,7 @@ const refreshController = async (req, res) => {
const session = await Session.findOne({ user: userId, refreshTokenHash: hashedToken });
if (session && session.expiration > new Date()) {
const token = await setAuthTokens(userId, res, session._id);
const userObj = user.toJSON();
res.status(200).send({ token, user: userObj });
res.status(200).send({ token, user });
} else if (req?.query?.retry) {
// Retrying from a refresh token request that failed (401)
res.status(403).send('No session found');
@ -115,7 +97,6 @@ const refreshController = async (req, res) => {
};
module.exports = {
getUserController,
refreshController,
registrationController,
resetPasswordController,

View file

@ -1,14 +1,16 @@
const {
User,
Session,
Balance,
deleteFiles,
deleteConvos,
deletePresets,
deleteMessages,
deleteUserById,
} = require('~/models');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
const { deleteAllSharedLinks } = require('~/models/Share');
const { Transaction } = require('~/models/Transaction');
const { logger } = require('~/config');
@ -59,7 +61,7 @@ const updateUserPluginsController = async (req, res) => {
res.status(200).send();
} catch (err) {
logger.error('[updateUserPluginsController]', err);
res.status(500).json({ message: err.message });
return res.status(500).json({ message: 'Something went wrong.' });
}
};
@ -75,18 +77,50 @@ const deleteUserController = async (req, res) => {
await deletePresets(user.id); // delete user presets
await deleteConvos(user.id); // delete user convos
await deleteUserPluginAuth(user.id, null, true); // delete user plugin auth
await User.deleteOne({ _id: user.id }); // delete user
await deleteUserById(user.id); // delete user
await deleteFiles(null, user.id); // delete user files
await deleteAllSharedLinks(user.id); // delete user shared links
/* TODO: queue job for cleaning actions and assistants of non-existant users */
logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`);
res.status(200).send({ message: 'User deleted' });
} catch (err) {
logger.error('[deleteUserController]', err);
res.status(500).send({ message: err.message });
return res.status(500).json({ message: 'Something went wrong.' });
}
};
const verifyEmailController = async (req, res) => {
try {
const verifyEmailService = await verifyEmail(req);
if (verifyEmailService instanceof Error) {
return res.status(400).json(verifyEmailService);
} else {
return res.status(200).json(verifyEmailService);
}
} catch (e) {
logger.error('[verifyEmailController]', e);
return res.status(500).json({ message: 'Something went wrong.' });
}
};
const resendVerificationController = async (req, res) => {
try {
const result = await resendVerificationEmail(req);
if (result instanceof Error) {
return res.status(400).json(result);
} else {
return res.status(200).json(result);
}
} catch (e) {
logger.error('[verifyEmailController]', e);
return res.status(500).json({ message: 'Something went wrong.' });
}
};
module.exports = {
getUserController,
updateUserPluginsController,
deleteUserController,
verifyEmailController,
updateUserPluginsController,
resendVerificationController,
};

View file

@ -1,17 +1,21 @@
const User = require('~/models/User');
const { setAuthTokens } = require('~/server/services/AuthService');
const { getUserById } = require('~/models/userMethods');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const loginController = async (req, res) => {
try {
const user = await User.findById(req.user._id);
const user = await getUserById(req.user._id, '-password -__v');
// If user doesn't exist, return error
if (!user) {
// typeof user !== User) { // this doesn't seem to resolve the User type ??
return res.status(400).json({ message: 'Invalid credentials' });
}
if (!user.emailVerified && !isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN)) {
return res.status(422).json({ message: 'Email not verified' });
}
const token = await setAuthTokens(user._id, res);
return res.status(200).send({ token, user });

View file

@ -1,11 +1,11 @@
const Keyv = require('keyv');
const uap = require('ua-parser-js');
const { ViolationTypes } = require('librechat-data-provider');
const { isEnabled, removePorts } = require('../utils');
const { isEnabled, removePorts } = require('~/server/utils');
const keyvMongo = require('~/cache/keyvMongo');
const denyRequest = require('./denyRequest');
const { getLogStores } = require('~/cache');
const User = require('~/models/User');
const { findUser } = require('~/models');
const banCache = new Keyv({ store: keyvMongo, namespace: ViolationTypes.BAN, ttl: 0 });
const message = 'Your account has been temporarily banned due to violations of our service.';
@ -55,7 +55,7 @@ const checkBan = async (req, res, next = () => {}) => {
let userId = req.user?.id ?? req.user?._id ?? null;
if (!userId && req?.body?.email) {
const user = await User.findOne({ email: req.body.email }, '_id').lean();
const user = await findUser({ email: req.body.email }, '_id');
userId = user?._id ? user._id.toString() : userId;
}

View file

@ -1,51 +1,43 @@
const abortMiddleware = require('./abortMiddleware');
const checkBan = require('./checkBan');
const checkDomainAllowed = require('./checkDomainAllowed');
const uaParser = require('./uaParser');
const setHeaders = require('./setHeaders');
const loginLimiter = require('./loginLimiter');
const validateModel = require('./validateModel');
const requireJwtAuth = require('./requireJwtAuth');
const requireLdapAuth = require('./requireLdapAuth');
const uploadLimiters = require('./uploadLimiters');
const registerLimiter = require('./registerLimiter');
const messageLimiters = require('./messageLimiters');
const requireLocalAuth = require('./requireLocalAuth');
const validateEndpoint = require('./validateEndpoint');
const concurrentLimiter = require('./concurrentLimiter');
const validateMessageReq = require('./validateMessageReq');
const buildEndpointOption = require('./buildEndpointOption');
const validatePasswordReset = require('./validatePasswordReset');
const validateRegistration = require('./validateRegistration');
const validateImageRequest = require('./validateImageRequest');
const validatePasswordReset = require('./validatePasswordReset');
const moderateText = require('./moderateText');
const noIndex = require('./noIndex');
const importLimiters = require('./importLimiters');
const buildEndpointOption = require('./buildEndpointOption');
const validateMessageReq = require('./validateMessageReq');
const checkDomainAllowed = require('./checkDomainAllowed');
const concurrentLimiter = require('./concurrentLimiter');
const validateEndpoint = require('./validateEndpoint');
const requireLocalAuth = require('./requireLocalAuth');
const canDeleteAccount = require('./canDeleteAccount');
const requireLdapAuth = require('./requireLdapAuth');
const abortMiddleware = require('./abortMiddleware');
const requireJwtAuth = require('./requireJwtAuth');
const validateModel = require('./validateModel');
const moderateText = require('./moderateText');
const setHeaders = require('./setHeaders');
const limiters = require('./limiters');
const uaParser = require('./uaParser');
const checkBan = require('./checkBan');
const noIndex = require('./noIndex');
module.exports = {
...uploadLimiters,
...abortMiddleware,
...messageLimiters,
...limiters,
noIndex,
checkBan,
uaParser,
setHeaders,
loginLimiter,
moderateText,
validateModel,
requireJwtAuth,
requireLdapAuth,
registerLimiter,
requireLocalAuth,
canDeleteAccount,
validateEndpoint,
concurrentLimiter,
checkDomainAllowed,
validateMessageReq,
buildEndpointOption,
validateRegistration,
validateImageRequest,
validatePasswordReset,
validateModel,
moderateText,
noIndex,
...importLimiters,
checkDomainAllowed,
canDeleteAccount,
};

View file

@ -0,0 +1,22 @@
const createTTSLimiters = require('./ttsLimiters');
const createSTTLimiters = require('./sttLimiters');
const loginLimiter = require('./loginLimiter');
const importLimiters = require('./importLimiters');
const uploadLimiters = require('./uploadLimiters');
const registerLimiter = require('./registerLimiter');
const messageLimiters = require('./messageLimiters');
const verifyEmailLimiter = require('./verifyEmailLimiter');
const resetPasswordLimiter = require('./resetPasswordLimiter');
module.exports = {
...uploadLimiters,
...importLimiters,
...messageLimiters,
loginLimiter,
registerLimiter,
createTTSLimiters,
createSTTLimiters,
verifyEmailLimiter,
resetPasswordLimiter,
};

View file

@ -1,6 +1,6 @@
const rateLimit = require('express-rate-limit');
const { logViolation } = require('../../cache');
const { removePorts } = require('../utils');
const { removePorts } = require('~/server/utils');
const { logViolation } = require('~/cache');
const { LOGIN_WINDOW = 5, LOGIN_MAX = 7, LOGIN_VIOLATION_SCORE: score } = process.env;
const windowMs = LOGIN_WINDOW * 60 * 1000;

View file

@ -1,6 +1,6 @@
const rateLimit = require('express-rate-limit');
const { logViolation } = require('../../cache');
const denyRequest = require('./denyRequest');
const denyRequest = require('~/server/middleware/denyRequest');
const { logViolation } = require('~/cache');
const {
MESSAGE_IP_MAX = 40,

View file

@ -1,6 +1,6 @@
const rateLimit = require('express-rate-limit');
const { logViolation } = require('../../cache');
const { removePorts } = require('../utils');
const { removePorts } = require('~/server/utils');
const { logViolation } = require('~/cache');
const { REGISTER_WINDOW = 60, REGISTER_MAX = 5, REGISTRATION_VIOLATION_SCORE: score } = process.env;
const windowMs = REGISTER_WINDOW * 60 * 1000;

View file

@ -0,0 +1,35 @@
const rateLimit = require('express-rate-limit');
const { ViolationTypes } = require('librechat-data-provider');
const { removePorts } = require('~/server/utils');
const { logViolation } = require('~/cache');
const {
RESET_PASSWORD_WINDOW = 2,
RESET_PASSWORD_MAX = 2,
RESET_PASSWORD_VIOLATION_SCORE: score,
} = process.env;
const windowMs = RESET_PASSWORD_WINDOW * 60 * 1000;
const max = RESET_PASSWORD_MAX;
const windowInMinutes = windowMs / 60000;
const message = `Too many attempts, please try again after ${windowInMinutes} minute(s)`;
const handler = async (req, res) => {
const type = ViolationTypes.RESET_PASSWORD_LIMIT;
const errorMessage = {
type,
max,
windowInMinutes,
};
await logViolation(req, res, type, errorMessage, score);
return res.status(429).json({ message });
};
const resetPasswordLimiter = rateLimit({
windowMs,
max,
handler,
keyGenerator: removePorts,
});
module.exports = resetPasswordLimiter;

View file

@ -0,0 +1,35 @@
const rateLimit = require('express-rate-limit');
const { ViolationTypes } = require('librechat-data-provider');
const { removePorts } = require('~/server/utils');
const { logViolation } = require('~/cache');
const {
VERIFY_EMAIL_WINDOW = 2,
VERIFY_EMAIL_MAX = 2,
VERIFY_EMAIL_VIOLATION_SCORE: score,
} = process.env;
const windowMs = VERIFY_EMAIL_WINDOW * 60 * 1000;
const max = VERIFY_EMAIL_MAX;
const windowInMinutes = windowMs / 60000;
const message = `Too many attempts, please try again after ${windowInMinutes} minute(s)`;
const handler = async (req, res) => {
const type = ViolationTypes.VERIFY_EMAIL_LIMIT;
const errorMessage = {
type,
max,
windowInMinutes,
};
await logViolation(req, res, type, errorMessage, score);
return res.status(429).json({ message });
};
const verifyEmailLimiter = rateLimit({
windowMs,
max,
handler,
keyGenerator: removePorts,
});
module.exports = verifyEmailLimiter;

View file

@ -21,7 +21,13 @@ const requireLocalAuth = (req, res, next) => {
log({
title: '(requireLocalAuth) Error: No user',
});
return res.status(422).send(info);
return res.status(404).send(info);
}
if (info && info.message) {
log({
title: '(requireLocalAuth) Error: ' + info.message,
});
return res.status(422).send({ message: info.message });
}
req.user = user;
next();

View file

@ -1,7 +0,0 @@
const createTTSLimiters = require('./ttsLimiters');
const createSTTLimiters = require('./sttLimiters');
module.exports = {
createTTSLimiters,
createSTTLimiters,
};

View file

@ -1,22 +1,23 @@
const express = require('express');
const {
resetPasswordRequestController,
resetPasswordController,
refreshController,
registrationController,
} = require('../controllers/AuthController');
const { loginController } = require('../controllers/auth/LoginController');
const { logoutController } = require('../controllers/auth/LogoutController');
resetPasswordController,
resetPasswordRequestController,
} = require('~/server/controllers/AuthController');
const { loginController } = require('~/server/controllers/auth/LoginController');
const { logoutController } = require('~/server/controllers/auth/LogoutController');
const {
checkBan,
loginLimiter,
registerLimiter,
requireJwtAuth,
registerLimiter,
requireLdapAuth,
requireLocalAuth,
resetPasswordLimiter,
validateRegistration,
validatePasswordReset,
} = require('../middleware');
} = require('~/server/middleware');
const router = express.Router();
@ -35,6 +36,7 @@ router.post('/refresh', refreshController);
router.post('/register', registerLimiter, checkBan, validateRegistration, registrationController);
router.post(
'/requestPasswordReset',
resetPasswordLimiter,
checkBan,
validatePasswordReset,
resetPasswordRequestController,

View file

@ -1,6 +1,12 @@
const express = require('express');
const { uaParser, checkBan, requireJwtAuth, createFileLimiters } = require('~/server/middleware');
const { createTTSLimiters, createSTTLimiters } = require('~/server/middleware/speech');
const {
uaParser,
checkBan,
requireJwtAuth,
createFileLimiters,
createTTSLimiters,
createSTTLimiters,
} = require('~/server/middleware');
const { createMulterInstance } = require('./multer');
const files = require('./files');

View file

@ -1,12 +1,12 @@
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
const passport = require('passport');
const express = require('express');
const router = express.Router();
const { setAuthTokens } = require('~/server/services/AuthService');
const passport = require('passport');
const { loginLimiter, checkBan, checkDomainAllowed } = require('~/server/middleware');
const { setAuthTokens } = require('~/server/services/AuthService');
const { logger } = require('~/config');
const router = express.Router();
const domains = {
client: process.env.DOMAIN_CLIENT,
server: process.env.DOMAIN_SERVER,

View file

@ -1,16 +1,19 @@
const express = require('express');
const requireJwtAuth = require('../middleware/requireJwtAuth');
const canDeleteAccount = require('../middleware/canDeleteAccount');
const { requireJwtAuth, canDeleteAccount, verifyEmailLimiter } = require('~/server/middleware');
const {
getUserController,
updateUserPluginsController,
deleteUserController,
} = require('../controllers/UserController');
verifyEmailController,
updateUserPluginsController,
resendVerificationController,
} = require('~/server/controllers/UserController');
const router = express.Router();
router.get('/', requireJwtAuth, getUserController);
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
router.delete('/delete', requireJwtAuth, canDeleteAccount, deleteUserController);
router.post('/verify', verifyEmailController);
router.post('/verify/resend', verifyEmailLimiter, resendVerificationController);
module.exports = router;

View file

@ -1,13 +1,20 @@
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { errorsToString } = require('librechat-data-provider');
const {
countUsers,
createUser,
findUser,
updateUser,
generateToken,
getUserById,
} = require('~/models/userMethods');
const { sendEmail, checkEmailConfig } = require('~/server/utils');
const { registerSchema } = require('~/strategies/validators');
const isDomainAllowed = require('./isDomainAllowed');
const Token = require('~/models/schema/tokenSchema');
const { sendEmail } = require('~/server/utils');
const Session = require('~/models/Session');
const { logger } = require('~/config');
const User = require('~/models/User');
const domains = {
client: process.env.DOMAIN_CLIENT,
@ -15,19 +22,7 @@ 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
);
}
const genericVerificationMessage = 'Please check your email to verify your email address.';
/**
* Logout user
@ -58,10 +53,73 @@ const logoutUser = async (userId, refreshToken) => {
};
/**
* Register a new user
*
* @param {Object} user <email, password, name, username>
* @returns
* Send Verification Email
* @param {Partial<MongoUser> & { _id: ObjectId, email: string, name: string}} user
* @returns {Promise<void>}
*/
const sendVerificationEmail = async (user) => {
let verifyToken = crypto.randomBytes(32).toString('hex');
const hash = bcrypt.hashSync(verifyToken, 10);
await new Token({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
}).save();
const verificationLink = `${domains.client}/verify?token=${verifyToken}&email=${user.email}`;
logger.info(`[sendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
sendEmail(
user.email,
'Verify your email',
{
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name,
verificationLink: verificationLink,
year: new Date().getFullYear(),
},
'verifyEmail.handlebars',
);
return;
};
/**
* Verify Email
* @param {Express.Request} req
*/
const verifyEmail = async (req) => {
const { email, token } = req.body;
let emailVerificationData = await Token.findOne({ email });
if (!emailVerificationData) {
logger.warn(`[verifyEmail] [No email verification data found] [Email: ${email}]`);
return new Error('Invalid or expired password reset token');
}
const isValid = bcrypt.compareSync(token, emailVerificationData.token);
if (!isValid) {
logger.warn(`[verifyEmail] [Invalid or expired email verification token] [Email: ${email}]`);
return new Error('Invalid or expired email verification token');
}
const updatedUser = await updateUser(emailVerificationData.userId, { emailVerified: true });
if (!updatedUser) {
logger.warn(`[verifyEmail] [User not found] [Email: ${email}]`);
return new Error('User not found');
}
await emailVerificationData.deleteOne();
logger.info(`[verifyEmail] Email verification successful. [Email: ${email}]`);
return { message: 'Email verification was successful' };
};
/**
* Register a new user.
* @param {MongoUser} user <email, password, name, username>
* @returns {Promise<{status: number, message: string, user?: MongoUser}>}
*/
const registerUser = async (user) => {
const { error } = registerSchema.safeParse(user);
@ -73,13 +131,13 @@ const registerUser = async (user) => {
{ name: 'Validation error:', value: errorMessage },
);
return { status: 422, message: errorMessage };
return { status: 404, message: errorMessage };
}
const { email, password, name, username } = user;
try {
const existingUser = await User.findOne({ email }).lean();
const existingUser = await findUser({ email }, 'email _id');
if (existingUser) {
logger.info(
@ -90,38 +148,46 @@ const registerUser = async (user) => {
// Sleep for 1 second
await new Promise((resolve) => setTimeout(resolve, 1000));
// TODO: We should change the process to always email and be generic is signup works or fails (user enum)
return { status: 500, message: 'Something went wrong' };
return { status: 200, message: genericVerificationMessage };
}
if (!(await isDomainAllowed(email))) {
const errorMessage = 'Registration from this domain is not allowed.';
const errorMessage =
'The email address provided cannot be used. Please use a different email address.';
logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`);
return { status: 403, message: errorMessage };
}
//determine if this is the first registered user (not counting anonymous_user)
const isFirstRegisteredUser = (await User.countDocuments({})) === 0;
const isFirstRegisteredUser = (await countUsers()) === 0;
const newUser = await new User({
const salt = bcrypt.genSaltSync(10);
const newUserData = {
provider: 'local',
email,
password,
username,
name,
avatar: null,
role: isFirstRegisteredUser ? 'ADMIN' : 'USER',
});
password: bcrypt.hashSync(password, salt),
};
const salt = bcrypt.genSaltSync(10);
const hash = bcrypt.hashSync(newUser.password, salt);
newUser.password = hash;
await newUser.save();
const emailEnabled = checkEmailConfig();
const newUserId = await createUser(newUserData, false);
if (emailEnabled) {
await sendVerificationEmail({
_id: newUserId,
email,
name,
});
} else {
await updateUser(newUserId, { emailVerified: true });
}
return { status: 200, user: newUser };
return { status: 200, message: genericVerificationMessage };
} catch (err) {
return { status: 500, message: err?.message || 'Something went wrong' };
logger.error('[registerUser] Error in registering user:', err);
return { status: 500, message: 'Something went wrong' };
}
};
@ -131,7 +197,7 @@ const registerUser = async (user) => {
*/
const requestPasswordReset = async (req) => {
const { email } = req.body;
const user = await User.findOne({ email }).lean();
const user = await findUser({ email }, 'email _id');
const emailEnabled = checkEmailConfig();
logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`);
@ -208,13 +274,9 @@ const resetPassword = async (userId, token, password) => {
}
const hash = bcrypt.hashSync(password, 10);
const user = await updateUser(userId, { password: hash });
await User.updateOne({ _id: userId }, { $set: { password: hash } }, { new: true });
const user = await User.findById({ _id: userId });
const emailEnabled = checkEmailConfig();
if (emailEnabled) {
if (checkEmailConfig()) {
sendEmail(
user.email,
'Password Reset Successfully',
@ -242,8 +304,8 @@ const resetPassword = async (userId, token, password) => {
*/
const setAuthTokens = async (userId, res, sessionId = null) => {
try {
const user = await User.findOne({ _id: userId });
const token = await user.generateToken();
const user = await getUserById(userId);
const token = await generateToken(user);
let session;
let refreshTokenExpires;
@ -273,11 +335,69 @@ const setAuthTokens = async (userId, res, sessionId = null) => {
}
};
/**
* Resend Verification Email
* @param {Object} req
* @param {Object} req.body
* @param {String} req.body.email
* @returns {Promise<{status: number, message: string}>}
*/
const resendVerificationEmail = async (req) => {
try {
const { email } = req.body;
await Token.deleteMany({ email });
const user = await findUser({ email }, 'email _id name');
if (!user) {
logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`);
return { status: 200, message: genericVerificationMessage };
}
let verifyToken = crypto.randomBytes(32).toString('hex');
const hash = bcrypt.hashSync(verifyToken, 10);
await new Token({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
}).save();
const verificationLink = `${domains.client}/verify?token=${verifyToken}&email=${user.email}`;
logger.info(`[resendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
sendEmail(
user.email,
'Verify your email',
{
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name,
verificationLink: verificationLink,
year: new Date().getFullYear(),
},
'verifyEmail.handlebars',
);
return {
status: 200,
message: genericVerificationMessage,
};
} catch (error) {
logger.error(`[resendVerificationEmail] Error resending verification email: ${error.message}`);
return {
status: 500,
message: 'Something went wrong.',
};
}
};
module.exports = {
registerUser,
logoutUser,
verifyEmail,
registerUser,
setAuthTokens,
resetPassword,
isDomainAllowed,
requestPasswordReset,
resetPassword,
setAuthTokens,
resendVerificationEmail,
};

View file

@ -1,6 +1,6 @@
const { ErrorTypes } = require('librechat-data-provider');
const { encrypt, decrypt } = require('~/server/utils');
const { User, Key } = require('~/models');
const { updateUser, Key } = require('~/models');
const { logger } = require('~/config');
/**
@ -16,16 +16,13 @@ const { logger } = require('~/config');
*/
const updateUserPluginsService = async (user, pluginKey, action) => {
try {
const userPlugins = user.plugins || [];
if (action === 'install') {
return await User.updateOne(
{ _id: user._id },
{ $set: { plugins: [...user.plugins, pluginKey] } },
);
return await updateUser(user._id, { plugins: [...userPlugins, pluginKey] });
} else if (action === 'uninstall') {
return await User.updateOne(
{ _id: user._id },
{ $set: { plugins: user.plugins.filter((plugin) => plugin !== pluginKey) } },
);
return await updateUser(user._id, {
plugins: userPlugins.filter((plugin) => plugin !== pluginKey),
});
}
} catch (err) {
logger.error('[updateUserPluginsService]', err);
@ -166,30 +163,12 @@ const checkUserKeyExpiry = (expiresAt, endpoint) => {
}
};
/**
* Retrieves a user document from the database based on the provided email.
* @async
* @param {string} email - The email of the user to find.
* @returns {Promise<Object|null>} The user document if found, otherwise null.
* @throws {Error} Throws an error if there is a problem during user retrieval.
*/
const findUserByEmail = async (email) => {
try {
const user = await User.findOne({ email });
return user;
} catch (error) {
logger.error(`[findUserByEmail] Error occurred while finding user by email: ${email}`, error);
throw error;
}
};
module.exports = {
updateUserPluginsService,
getUserKey,
getUserKeyValues,
getUserKeyExpiry,
updateUserKey,
deleteUserKey,
getUserKeyValues,
getUserKeyExpiry,
checkUserKeyExpiry,
findUserByEmail,
updateUserPluginsService,
};

View file

@ -3,7 +3,7 @@ const {
deprecatedAzureVariables,
conflictingAzureVariables,
} = require('librechat-data-provider');
const { isEnabled } = require('~/server/utils');
const { isEnabled, checkEmailConfig } = require('~/server/utils');
const { logger } = require('~/config');
const secretDefaults = {
@ -111,12 +111,7 @@ 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 emailEnabled = checkEmailConfig();
const passwordResetAllowed = isEnabled(process.env.ALLOW_PASSWORD_RESET);
if (!emailEnabled && passwordResetAllowed) {

View file

@ -1,9 +1,11 @@
<!DOCTYPE HTML
PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<html
xmlns='http://www.w3.org/1999/xhtml'
xmlns:v='urn:schemas-microsoft-com:vml'
xmlns:o='urn:schemas-microsoft-com:office:office'
>
<head>
<!--[if gte mso 9]>
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
@ -11,176 +13,184 @@
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="x-apple-disable-message-reformatting">
<meta name="color-scheme" content="light dark">
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<title></title>
<style type="text/css">
@media (prefers-color-scheme: dark) {
.darkmode {
background-color: #212121 !important;
}
.darkmode p {
color: #ffffff !important;
}
}
@media only screen and (min-width: 520px) {
.u-row {
width: 500px !important;
}
.u-row .u-col {
vertical-align: top;
}
.u-row .u-col-100 {
width: 500px !important;
}
}
@media (max-width: 520px) {
.u-row-container {
max-width: 100% !important;
padding-left: 0px !important;
padding-right: 0px !important;
}
.u-row .u-col {
min-width: 320px !important;
max-width: 100% !important;
display: block !important;
}
.u-row {
width: 100% !important;
}
.u-col {
width: 100% !important;
}
.u-col>div {
margin: 0 auto;
}
}
body {
margin: 0;
padding: 0;
}
table,
tr,
td {
vertical-align: top;
border-collapse: collapse;
}
.ie-container table,
.mso-container table {
table-layout: fixed;
}
* {
line-height: inherit;
}
a[x-apple-data-detectors='true'] {
color: inherit !important;
text-decoration: none !important;
}
table,
td {
color: #ffffff;
}
</style>
</head>
<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name='x-apple-disable-message-reformatting' />
<meta name='color-scheme' content='light dark' />
<!--[if !mso]><!-->
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<!--<![endif]-->
<title></title>
<style type='text/css'>
@media (prefers-color-scheme: dark) { .darkmode { background-color: #212121 !important; }
.darkmode p { color: #ffffff !important; } } @media only screen and (min-width: 520px) {
.u-row { width: 500px !important; } .u-row .u-col { vertical-align: top; } .u-row .u-col-100 {
width: 500px !important; } } @media (max-width: 520px) { .u-row-container { max-width: 100%
!important; padding-left: 0px !important; padding-right: 0px !important; } .u-row .u-col {
min-width: 320px !important; max-width: 100% !important; display: block !important; } .u-row {
width: 100% !important; } .u-col { width: 100% !important; } .u-col>div { margin: 0 auto; } }
body { margin: 0; padding: 0; } table, tr, td { vertical-align: top; border-collapse:
collapse; } .ie-container table, .mso-container table { table-layout: fixed; } * {
line-height: inherit; } a[x-apple-data-detectors='true'] { color: inherit !important;
text-decoration: none !important; } table, td { color: #ffffff; }
</style>
</head>
<body class="clean-body u_body" style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff">
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%" cellpadding="0" cellspacing="0">
<tbody>
<tr style="vertical-align: top">
<td style="word-break: break-word;border-collapse: collapse !important;vertical-align: top">
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
<div class="u-row-container" style="padding: 0px;background-color: transparent">
<div class="u-row" style="margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;">
<div style="border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;">
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div class="u-col u-col-100" style="max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;">
<div style="background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;">
<!--[if (!mso)&(!IE)]><!-->
<div style="box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;">
<!--<![endif]-->
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
<div style="font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;">
<div>Hi {{name}},</div>
</div>
</td>
</tr>
</tbody>
</table>
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
<div style="font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;">
<div>
<div>Your password has been updated successfully! </div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
<div style="font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;">
<div>Best regards,</div>
<div>The {{appName}} Team</div>
</div>
</td>
</tr>
</tbody>
</table>
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;" align="left">
<div style="font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;">
<div>
<div><sub>© {{year}} {{appName}}. All rights
reserved.</sub></div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
<body
class='clean-body u_body'
style='margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff'
>
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
style='border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%'
cellpadding='0'
cellspacing='0'
>
<tbody>
<tr style='vertical-align: top'>
<td
style='word-break: break-word;border-collapse: collapse !important;vertical-align: top'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
<div class='u-row-container' style='padding: 0px;background-color: transparent'>
<div
class='u-row'
style='margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;'
>
<div
style='border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div
class='u-col u-col-100'
style='max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;'
>
<div
style='background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--[if (!mso)&(!IE)]><!-->
<div
style='box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--<![endif]-->
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Hi {{name}},</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>Your password has been updated successfully! </div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Best regards,</div>
<div>The {{appName}} Team</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;'
>
<div>
<div><sub>©
{{year}}
{{appName}}. All rights reserved.</sub></div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>

View file

@ -1,9 +1,11 @@
<!DOCTYPE HTML
PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<html
xmlns='http://www.w3.org/1999/xhtml'
xmlns:v='urn:schemas-microsoft-com:vml'
xmlns:o='urn:schemas-microsoft-com:office:office'
>
<head>
<!--[if gte mso 9]>
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
@ -11,229 +13,272 @@
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="x-apple-disable-message-reformatting">
<meta name="color-scheme" content="light dark">
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<title></title>
<style type="text/css">
@media (prefers-color-scheme: dark) {
.darkmode {
background-color: #212121 !important;
}
.darkmode p {
color: #ffffff !important;
}
}
@media only screen and (min-width: 520px) {
.u-row {
width: 500px !important;
}
.u-row .u-col {
vertical-align: top;
}
.u-row .u-col-100 {
width: 500px !important;
}
}
@media (max-width: 520px) {
.u-row-container {
max-width: 100% !important;
padding-left: 0px !important;
padding-right: 0px !important;
}
.u-row .u-col {
min-width: 320px !important;
max-width: 100% !important;
display: block !important;
}
.u-row {
width: 100% !important;
}
.u-col {
width: 100% !important;
}
.u-col>div {
margin: 0 auto;
}
}
body {
margin: 0;
padding: 0;
}
table,
tr,
td {
vertical-align: top;
border-collapse: collapse;
}
p {
margin: 0;
}
.ie-container table,
.mso-container table {
table-layout: fixed;
}
* {
line-height: inherit;
}
a[x-apple-data-detectors='true'] {
color: inherit !important;
text-decoration: none !important;
}
table,
td {
color: #ffffff;
}
#u_body a {
color: #0000ee;
text-decoration: underline;
}
</style>
</head>
<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name='x-apple-disable-message-reformatting' />
<meta name='color-scheme' content='light dark' />
<!--[if !mso]><!-->
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<!--<![endif]-->
<title></title>
<style type='text/css'>
@media (prefers-color-scheme: dark) { .darkmode { background-color: #212121 !important; }
.darkmode p { color: #ffffff !important; } } @media only screen and (min-width: 520px) {
.u-row { width: 500px !important; } .u-row .u-col { vertical-align: top; } .u-row .u-col-100 {
width: 500px !important; } } @media (max-width: 520px) { .u-row-container { max-width: 100%
!important; padding-left: 0px !important; padding-right: 0px !important; } .u-row .u-col {
min-width: 320px !important; max-width: 100% !important; display: block !important; } .u-row {
width: 100% !important; } .u-col { width: 100% !important; } .u-col>div { margin: 0 auto; } }
body { margin: 0; padding: 0; } table, tr, td { vertical-align: top; border-collapse:
collapse; } p { margin: 0; } .ie-container table, .mso-container table { table-layout: fixed;
} * { line-height: inherit; } a[x-apple-data-detectors='true'] { color: inherit !important;
text-decoration: none !important; } table, td { color: #ffffff; } #u_body a { color: #0000ee;
text-decoration: underline; }
</style>
</head>
<body class="clean-body u_body" style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff">
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table id="u_body" style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%" cellpadding="0" cellspacing="0">
<tbody>
<tr style="vertical-align: top">
<td style="word-break: break-word;border-collapse: collapse !important;vertical-align: top">
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
<div class="u-row-container" style="padding: 0px;background-color: transparent">
<div class="u-row" style="margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;">
<div style="border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;">
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div class="u-col u-col-100" style="max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;">
<div style="background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;">
<!--[if (!mso)&(!IE)]><!-->
<div style="box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;">
<!--<![endif]-->
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
<!--[if mso]><table width="100%"><tr><td><![endif]-->
<h1 style="margin: 0px; line-height: 140%; text-align: left; word-wrap: break-word; font-size: 22px; font-weight: 700;">
<div>
<div>You have requested to reset your password.
</div>
</div>
</h1>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
<div style="font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;">
<div>Hi {{name}},</div>
</div>
</td>
</tr>
</tbody>
</table>
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
<div style="font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;">
<p style="line-height: 140%;">Please click the button below to reset your password.</p>
</div>
</td>
</tr>
</tbody>
</table>
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
<!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
<div align="left">
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{link}}" style="height:37px; v-text-anchor:middle; width:142px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
<a href="{{link}}" target="_blank" class="v-button" style="box-sizing: border-box;display: inline-block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #FFFFFF; background-color: #10a37f; border-radius: 4px;-webkit-border-radius: 4px; -moz-border-radius: 4px; width:auto; max-width:100%; overflow-wrap: break-word; word-break: break-word; word-wrap:break-word; mso-border-alt: none;font-size: 14px;"> <span style="display:block;padding:10px 20px;line-height:120%;"><span
style="line-height: 16.8px;">Reset
Password</span></span>
</a>
<!--[if mso]></center></v:roundrect><![endif]-->
</div>
</td>
</tr>
</tbody>
</table>
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
<div style="font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;">
<div>
<div>If you did not request a password reset, please ignore this email.</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
<div style="font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;">
<div>Best regards,</div>
<div>The {{appName}} Team</div>
</div>
</td>
</tr>
</tbody>
</table>
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;" align="left">
<div style="font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;">
<div>
<div><sub>© {{year}} {{appName}}. All rights
reserved.</sub></div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
<body
class='clean-body u_body'
style='margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff'
>
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
id='u_body'
style='border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%'
cellpadding='0'
cellspacing='0'
>
<tbody>
<tr style='vertical-align: top'>
<td
style='word-break: break-word;border-collapse: collapse !important;vertical-align: top'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
<div class='u-row-container' style='padding: 0px;background-color: transparent'>
<div
class='u-row'
style='margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;'
>
<div
style='border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div
class='u-col u-col-100'
style='max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;'
>
<div
style='background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--[if (!mso)&(!IE)]><!-->
<div
style='box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--<![endif]-->
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><table width="100%"><tr><td><![endif]-->
<h1
style='margin: 0px; line-height: 140%; text-align: left; word-wrap: break-word; font-size: 22px; font-weight: 700;'
>
<div>
<div>You have requested to reset your password.
</div>
</div>
</h1>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Hi {{name}},</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<p style='line-height: 140%;'>Please click the button below to
reset your password.</p>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
<div align='left'>
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{link}}" style="height:37px; v-text-anchor:middle; width:142px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
<a
href='{{link}}'
target='_blank'
class='v-button'
style='box-sizing: border-box;display: inline-block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #FFFFFF; background-color: #10a37f; border-radius: 4px;-webkit-border-radius: 4px; -moz-border-radius: 4px; width:auto; max-width:100%; overflow-wrap: break-word; word-break: break-word; word-wrap:break-word; mso-border-alt: none;font-size: 14px;'
>
<span
style='display:block;padding:10px 20px;line-height:120%;'
><span style='line-height: 16.8px;'>Reset Password</span></span>
</a>
<!--[if mso]></center></v:roundrect><![endif]-->
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>If you did not request a password reset, please ignore this
email.</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Best regards,</div>
<div>The {{appName}} Team</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;'
>
<div>
<div><sub>©
{{year}}
{{appName}}. All rights reserved.</sub></div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>

View file

@ -1,9 +1,11 @@
<!DOCTYPE HTML
PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<html
xmlns='http://www.w3.org/1999/xhtml'
xmlns:v='urn:schemas-microsoft-com:vml'
xmlns:o='urn:schemas-microsoft-com:office:office'
>
<head>
<!--[if gte mso 9]>
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
@ -11,229 +13,278 @@
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="x-apple-disable-message-reformatting">
<meta name="color-scheme" content="light dark">
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<title></title>
<style type="text/css">
@media (prefers-color-scheme: dark) {
.darkmode {
background-color: #212121 !important;
}
.darkmode p {
color: #ffffff !important;
}
}
@media only screen and (min-width: 520px) {
.u-row {
width: 500px !important;
}
.u-row .u-col {
vertical-align: top;
}
.u-row .u-col-100 {
width: 500px !important;
}
}
@media (max-width: 520px) {
.u-row-container {
max-width: 100% !important;
padding-left: 0px !important;
padding-right: 0px !important;
}
.u-row .u-col {
min-width: 320px !important;
max-width: 100% !important;
display: block !important;
}
.u-row {
width: 100% !important;
}
.u-col {
width: 100% !important;
}
.u-col>div {
margin: 0 auto;
}
}
body {
margin: 0;
padding: 0;
}
table,
tr,
td {
vertical-align: top;
border-collapse: collapse;
}
.ie-container table,
.mso-container table {
table-layout: fixed;
}
* {
line-height: inherit;
}
a[x-apple-data-detectors='true'] {
color: inherit !important;
text-decoration: none !important;
}
table,
td {
color: #ffffff;
}
#u_body a {
color: #0000ee;
text-decoration: underline;
}
</style>
</head>
<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name='x-apple-disable-message-reformatting' />
<meta name='color-scheme' content='light dark' />
<!--[if !mso]><!-->
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<!--<![endif]-->
<title></title>
<style type='text/css'>
@media (prefers-color-scheme: dark) { .darkmode { background-color: #212121 !important; }
.darkmode p { color: #ffffff !important; } } @media only screen and (min-width: 520px) {
.u-row { width: 500px !important; } .u-row .u-col { vertical-align: top; } .u-row .u-col-100 {
width: 500px !important; } } @media (max-width: 520px) { .u-row-container { max-width: 100%
!important; padding-left: 0px !important; padding-right: 0px !important; } .u-row .u-col {
min-width: 320px !important; max-width: 100% !important; display: block !important; } .u-row {
width: 100% !important; } .u-col { width: 100% !important; } .u-col>div { margin: 0 auto; } }
body { margin: 0; padding: 0; } table, tr, td { vertical-align: top; border-collapse:
collapse; } .ie-container table, .mso-container table { table-layout: fixed; } * {
line-height: inherit; } a[x-apple-data-detectors='true'] { color: inherit !important;
text-decoration: none !important; } table, td { color: #ffffff; } #u_body a { color: #0000ee;
text-decoration: underline; }
</style>
</head>
<body class="clean-body u_body" style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff">
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table id="u_body" style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%" cellpadding="0" cellspacing="0">
<tbody>
<tr style="vertical-align: top">
<td style="word-break: break-word;border-collapse: collapse !important;vertical-align: top">
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
<div class="u-row-container" style="padding: 0px;background-color: transparent">
<div class="u-row" style="margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;">
<div style="border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;">
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div class="u-col u-col-100" style="max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;">
<div style="background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;">
<!--[if (!mso)&(!IE)]><!-->
<div style="box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;">
<!--<![endif]-->
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
<!--[if mso]><table width="100%"><tr><td><![endif]-->
<h1 style="margin: 0px; line-height: 140%; text-align: left; word-wrap: break-word; font-size: 22px; font-weight: 700;">
<div>
<div>Welcome to {{appName}}!</div>
</div>
</h1>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
<div style="font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;">
<div>
<div>Dear {{name}},</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
<div style="font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;">
<div>
<div>Thank you for registering with {{appName}}. To complete your registration and verify your email address, please click the button below:</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
<!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
<div align="left">
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="href=&quot;{{verificationLink}}&quot;" style="height:37px; v-text-anchor:middle; width:114px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
<a href="href=&quot;{{verificationLink}}&quot;" target="_blank" class="v-button" style="box-sizing: border-box;display: inline-block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #FFFFFF; background-color: #10a37f; border-radius: 4px;-webkit-border-radius: 4px; -moz-border-radius: 4px; width:auto; max-width:100%; overflow-wrap: break-word; word-break: break-word; word-wrap:break-word; mso-border-alt: none;font-size: 14px;"> <span style="display:block;padding:10px 20px;line-height:120%;">
<div>
<div>Verify Email</div>
</div>
</span> </a>
<!--[if mso]></center></v:roundrect><![endif]-->
</div>
</td>
</tr>
</tbody>
</table>
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
<div style="font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;">
<div>
<div>If you did not create an account with {{appName}}, please ignore this email.</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
<div style="font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;">
<div>Best regards,</div>
<div>The {{appName}} Team</div>
</div>
</td>
</tr>
</tbody>
</table>
<table style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;" align="left">
<div style="font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;">
<div>
<div><sub>© {{year}} {{appName}}. All rights
reserved.</sub></div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
<body
class='clean-body u_body'
style='margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff'
>
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
id='u_body'
style='border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%'
cellpadding='0'
cellspacing='0'
>
<tbody>
<tr style='vertical-align: top'>
<td
style='word-break: break-word;border-collapse: collapse !important;vertical-align: top'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
<div class='u-row-container' style='padding: 0px;background-color: transparent'>
<div
class='u-row'
style='margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;'
>
<div
style='border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div
class='u-col u-col-100'
style='max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;'
>
<div
style='background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--[if (!mso)&(!IE)]><!-->
<div
style='box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--<![endif]-->
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><table width="100%"><tr><td><![endif]-->
<h1
style='margin: 0px; line-height: 140%; text-align: left; word-wrap: break-word; font-size: 22px; font-weight: 700;'
>
<div>
<div>Welcome to {{appName}}!</div>
</div>
</h1>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>Dear {{name}},</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>Thank you for registering with
{{appName}}. To complete your registration and verify your
email address, please click the button below:</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
<div align='left'>
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="href=&quot;{{verificationLink}}&quot;" style="height:37px; v-text-anchor:middle; width:114px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
<a
href='{{verificationLink}}'
target='_blank'
class='v-button'
style='box-sizing: border-box;display: inline-block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #FFFFFF; background-color: #10a37f; border-radius: 4px;-webkit-border-radius: 4px; -moz-border-radius: 4px; width:auto; max-width:100%; overflow-wrap: break-word; word-break: break-word; word-wrap:break-word; mso-border-alt: none;font-size: 14px;'
>
<span style='display:block;padding:10px 20px;line-height:120%;'>
<div>
<div>Verify Email</div>
</div>
</span>
</a>
<!--[if mso]></center></v:roundrect><![endif]-->
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>If you did not create an account with
{{appName}}, please ignore this email.</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Best regards,</div>
<div>The {{appName}} Team</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;'
>
<div>
<div><sub>©
{{year}}
{{appName}}. All rights reserved.</sub></div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>

View file

@ -135,7 +135,7 @@ async function importLibreChatConvo(
});
}
if (!firstMessageDate) {
if (!firstMessageDate && message.createdAt) {
firstMessageDate = new Date(message.createdAt);
}
@ -150,7 +150,7 @@ async function importLibreChatConvo(
const idMapping = new Map();
for (const message of messagesToImport) {
if (!firstMessageDate) {
if (!firstMessageDate && message.createdAt) {
firstMessageDate = new Date(message.createdAt);
}
const newMessageId = uuidv4();
@ -171,6 +171,10 @@ async function importLibreChatConvo(
throw new Error('Invalid LibreChat file format');
}
if (firstMessageDate === 'Invalid Date') {
firstMessageDate = null;
}
importBatchBuilder.finishConversation(jsonData.title, firstMessageDate ?? new Date(), options);
await importBatchBuilder.saveBatch();
logger.debug(`user: ${requestUserId} | Conversation "${jsonData.title}" imported`);

View file

@ -2,15 +2,29 @@ const streamResponse = require('./streamResponse');
const removePorts = require('./removePorts');
const countTokens = require('./countTokens');
const handleText = require('./handleText');
const cryptoUtils = require('./crypto');
const citations = require('./citations');
const sendEmail = require('./sendEmail');
const cryptoUtils = require('./crypto');
const queue = require('./queue');
const files = require('./files');
const math = require('./math');
/**
* 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
);
}
module.exports = {
...streamResponse,
checkEmailConfig,
...cryptoUtils,
...handleText,
...citations,

View file

@ -17,6 +17,7 @@ const getProfileDetails = (profile) => {
avatarUrl,
username: profile.username,
name: profile.global_name,
emailVerified: true,
};
};

View file

@ -7,6 +7,7 @@ const getProfileDetails = (profile) => ({
avatarUrl: profile.photos[0]?.value,
username: profile.displayName,
name: profile.name?.givenName + ' ' + profile.name?.familyName,
emailVerified: true,
});
const facebookLogin = socialLogin('facebook', getProfileDetails);

View file

@ -1,43 +0,0 @@
/*
const jose = require('jose');
const { logger } = require('~/config');
// No longer using this strategy as Bun now supports JWTs natively.
const passportCustom = require('passport-custom');
const CustomStrategy = passportCustom.Strategy;
const User = require('~/models/User');
const joseLogin = async () =>
new CustomStrategy(async (req, done) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return done(null, false, { message: 'No auth token' });
}
const token = authHeader.split(' ')[1];
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const { payload } = await jose.jwtVerify(token, secret);
const user = await User.findById(payload.id);
if (user) {
done(null, user);
} else {
logger.debug('JoseJwtStrategy => no user found');
done(null, false, { message: 'No user found' });
}
} catch (err) {
if (err?.code === 'ERR_JWT_EXPIRED') {
logger.error('JoseJwtStrategy => token expired');
} else {
logger.error('JoseJwtStrategy => error');
logger.error(err);
}
done(null, false, { message: 'Invalid token' });
}
});
module.exports = joseLogin;
*/

View file

@ -1,6 +1,6 @@
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { getUserById } = require('~/models');
const { logger } = require('~/config');
const User = require('~/models/User');
// JWT strategy
const jwtLogin = async () =>
@ -11,7 +11,8 @@ const jwtLogin = async () =>
},
async (payload, done) => {
try {
const user = await User.findById(payload?.id);
const user = await getUserById(payload?.id);
user.id = user._id.toString();
if (user) {
done(null, user);
} else {

View file

@ -1,5 +1,5 @@
const LdapStrategy = require('passport-ldapauth');
const User = require('~/models/User');
const { findUser, createUser } = require('~/models/userMethods');
const fs = require('fs');
const ldapOptions = {
@ -36,19 +36,19 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
userinfo.mail;
const username = userinfo.givenName || userinfo.mail;
let user = await User.findOne({ email: userinfo.mail });
let user = await findUser({ email: userinfo.mail });
if (user && user.provider !== 'ldap') {
return done(null, false, { message: 'Invalid credentials' });
}
if (!user) {
user = new User({
user = {
provider: 'ldap',
ldapId: userinfo.uid,
username,
email: userinfo.mail || '',
emailVerified: true,
name: fullName,
});
};
} else {
user.provider = 'ldap';
user.ldapId = userinfo.uid;
@ -56,7 +56,7 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
user.name = fullName;
}
await user.save();
await createUser(user);
done(null, user);
} catch (err) {

View file

@ -1,29 +1,15 @@
const { errorsToString } = require('librechat-data-provider');
const { Strategy: PassportLocalStrategy } = require('passport-local');
const { findUser, comparePassword } = require('~/models');
const { loginSchema } = require('./validators');
const { isEnabled } = require('~/server/utils');
const logger = require('~/utils/logger');
const User = require('~/models/User');
async function validateLoginRequest(req) {
const { error } = loginSchema.safeParse(req.body);
return error ? errorsToString(error.errors) : null;
}
async function findUserByEmail(email) {
return User.findOne({ email: email.trim() });
}
async function comparePassword(user, password) {
return new Promise((resolve, reject) => {
user.comparePassword(password, function (err, isMatch) {
if (err) {
return reject(err);
}
resolve(isMatch);
});
});
}
async function passportLogin(req, email, password, done) {
try {
const validationError = await validateLoginRequest(req);
@ -33,7 +19,7 @@ async function passportLogin(req, email, password, done) {
return done(null, false, { message: validationError });
}
const user = await findUserByEmail(email);
const user = await findUser({ email: email.trim() });
if (!user) {
logError('Passport Local Strategy - User Not Found', { email });
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
@ -47,6 +33,12 @@ async function passportLogin(req, email, password, done) {
return done(null, false, { message: 'Incorrect password.' });
}
if (!user.emailVerified && !isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN)) {
logError('Passport Local Strategy - Email not verified', { email });
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
return done(null, user, { message: 'Email not verified.' });
}
logger.info(`[Login] [Login successful] [Username: ${email}] [Request-IP: ${req.ip}]`);
return done(null, user);
} catch (err) {

View file

@ -3,8 +3,8 @@ const passport = require('passport');
const jwtDecode = require('jsonwebtoken/decode');
const { Issuer, Strategy: OpenIDStrategy } = require('openid-client');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { findUser, createUser, updateUser } = require('~/models/userMethods');
const { logger } = require('~/config');
const User = require('~/models/User');
let crypto;
try {
@ -88,13 +88,13 @@ async function setupOpenId() {
logger.info(`[openidStrategy] verify login openidId: ${userinfo.sub}`);
logger.debug('[openidStrategy] very login tokenset and userinfo', { tokenset, userinfo });
let user = await User.findOne({ openidId: userinfo.sub });
let user = await findUser({ openidId: userinfo.sub });
logger.info(
`[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${userinfo.sub}`,
);
if (!user) {
user = await User.findOne({ email: userinfo.email });
user = await findUser({ email: userinfo.email });
logger.info(
`[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${
userinfo.email
@ -148,14 +148,16 @@ async function setupOpenId() {
);
if (!user) {
user = new User({
user = {
provider: 'openid',
openidId: userinfo.sub,
username,
email: userinfo.email || '',
emailVerified: userinfo.email_verified || false,
name: fullName,
});
};
const userId = await createUser();
user._id = userId;
} else {
user.provider = 'openid';
user.openidId = userinfo.sub;
@ -163,7 +165,7 @@ async function setupOpenId() {
user.name = fullName;
}
if (userinfo.picture) {
if (userinfo.picture && !user.avatar?.includes('manual=true')) {
/** @type {string | undefined} */
const imageUrl = userinfo.picture;
@ -177,25 +179,21 @@ async function setupOpenId() {
}
const imageBuffer = await downloadImage(imageUrl, tokenset.access_token);
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
if (imageBuffer) {
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
const imagePath = await saveBuffer({
fileName,
userId: user._id.toString(),
buffer: imageBuffer,
});
user.avatar = imagePath ?? '';
} else {
user.avatar = '';
}
} else {
user.avatar = '';
}
await user.save();
user = await updateUser(user._id, user);
logger.info(
`[openidStrategy] login success openidId: ${user.openidId} username: ${user.username} email: ${user.email}`,
`[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `,
{
user: {
openidId: user.openidId,

View file

@ -1,14 +1,14 @@
const { FileSources } = require('librechat-data-provider');
const { createUser, updateUser, getUserById } = require('~/models/userMethods');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const User = require('~/models/User');
/**
* Updates the avatar URL of an existing user. If the user's avatar URL does not include the query parameter
* '?manual=true', it updates the user's avatar with the provided URL. For local file storage, it directly updates
* the avatar URL, while for other storage types, it processes the avatar URL using the specified file strategy.
*
* @param {User} oldUser - The existing user object that needs to be updated.
* @param {MongoUser} oldUser - The existing user object that needs to be updated.
* @param {string} avatarUrl - The new avatar URL to be set for the user.
*
* @returns {Promise<void>}
@ -20,9 +20,9 @@ const handleExistingUser = async (oldUser, avatarUrl) => {
const fileStrategy = process.env.CDN_PROVIDER;
const isLocal = fileStrategy === FileSources.local;
let updatedAvatar = false;
if (isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
oldUser.avatar = avatarUrl;
await oldUser.save();
updatedAvatar = avatarUrl;
} else if (!isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
const userId = oldUser._id;
const resizedBuffer = await resizeAvatar({
@ -30,8 +30,11 @@ const handleExistingUser = async (oldUser, avatarUrl) => {
input: avatarUrl,
});
const { processAvatar } = getStrategyFunctions(fileStrategy);
oldUser.avatar = await processAvatar({ buffer: resizedBuffer, userId });
await oldUser.save();
updatedAvatar = await processAvatar({ buffer: resizedBuffer, userId });
}
if (updatedAvatar) {
await updateUser(oldUser._id, { avatar: updatedAvatar });
}
};
@ -55,7 +58,7 @@ const handleExistingUser = async (oldUser, avatarUrl) => {
*
* @throws {Error} Throws an error if there's an issue creating or saving the new user object.
*/
const createNewUser = async ({
const createSocialUser = async ({
email,
avatarUrl,
provider,
@ -75,27 +78,24 @@ const createNewUser = async ({
emailVerified,
};
// TODO: remove direct access of User model
const newUser = await new User(update).save();
const newUserId = await createUser(update);
const fileStrategy = process.env.CDN_PROVIDER;
const isLocal = fileStrategy === FileSources.local;
if (!isLocal) {
const userId = newUser._id;
const resizedBuffer = await resizeAvatar({
userId,
userId: newUserId,
input: avatarUrl,
});
const { processAvatar } = getStrategyFunctions(fileStrategy);
newUser.avatar = await processAvatar({ buffer: resizedBuffer, userId });
await newUser.save();
const avatar = await processAvatar({ buffer: resizedBuffer, userId: newUserId });
await updateUser(newUserId, { avatar });
}
return newUser;
return await getUserById(newUserId);
};
module.exports = {
handleExistingUser,
createNewUser,
createSocialUser,
};

View file

@ -1,14 +1,14 @@
const { createNewUser, handleExistingUser } = require('./process');
const { logger } = require('~/config');
const { findUserByEmail } = require('~/server/services/UserService');
const { createSocialUser, handleExistingUser } = require('./process');
const { isEnabled } = require('~/server/utils');
const { findUser } = require('~/models');
const { logger } = require('~/config');
const socialLogin =
(provider, getProfileDetails) => async (accessToken, refreshToken, profile, cb) => {
try {
const { email, id, avatarUrl, username, name, emailVerified } = getProfileDetails(profile);
const oldUser = await findUserByEmail(email);
const oldUser = await findUser({ email: email.trim() });
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
if (oldUser) {
@ -17,7 +17,7 @@ const socialLogin =
}
if (ALLOW_SOCIAL_REGISTRATION) {
const newUser = await createNewUser({
const newUser = await createSocialUser({
email,
avatarUrl,
provider,

View file

@ -476,12 +476,30 @@
* @memberof typedefs
*/
/**
* @exports MongooseSchema
* @typedef {import('mongoose').Schema} MongooseSchema
* @memberof typedefs
*/
/**
* @exports ObjectId
* @typedef {import('mongoose').Types.ObjectId} ObjectId
* @memberof typedefs
*/
/**
* @exports MongoFile
* @typedef {import('~/models/schema/fileSchema.js').MongoFile} MongoFile
* @memberof typedefs
*/
/**
* @exports MongoUser
* @typedef {import('~/models/schema/userSchema.js').MongoUser} MongoUser
* @memberof typedefs
*/
/**
* @exports uploadImageBuffer
* @typedef {import('~/server/services/Files/process').uploadImageBuffer} uploadImageBuffer

View file

@ -324,6 +324,7 @@ export type TAuthContext = {
error: string | undefined;
login: (data: TLoginUser) => void;
logout: () => void;
setError: React.Dispatch<React.SetStateAction<string | undefined>>;
};
export type TUserContext = {

View file

@ -8,14 +8,19 @@ import LoginForm from './LoginForm';
function Login() {
const localize = useLocalize();
const { error, login } = useAuthContext();
const { error, setError, login } = useAuthContext();
const { startupConfig } = useOutletContext<TLoginLayoutContext>();
return (
<>
{error && <ErrorMessage>{localize(getLoginError(error))}</ErrorMessage>}
{startupConfig?.emailLoginEnabled && (
<LoginForm onSubmit={login} startupConfig={startupConfig} />
<LoginForm
onSubmit={login}
startupConfig={startupConfig}
error={error}
setError={setError}
/>
)}
{startupConfig?.registrationEnabled && (
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">

View file

@ -1,20 +1,40 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import React, { useState, useEffect } from 'react';
import type { TLoginUser, TStartupConfig } from 'librechat-data-provider';
import type { TAuthContext } from '~/common';
import { useResendVerificationEmail } from '~/data-provider';
import { useLocalize } from '~/hooks';
type TLoginFormProps = {
onSubmit: (data: TLoginUser) => void;
startupConfig: TStartupConfig;
error: Pick<TAuthContext, 'error'>['error'];
setError: Pick<TAuthContext, 'setError'>['setError'];
};
const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig }) => {
const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error, setError }) => {
const localize = useLocalize();
const {
register,
getValues,
handleSubmit,
formState: { errors },
} = useForm<TLoginUser>();
const [showResendLink, setShowResendLink] = useState<boolean>(false);
useEffect(() => {
if (error && error.includes('422') && !showResendLink) {
setShowResendLink(true);
}
}, [error, showResendLink]);
const resendLinkMutation = useResendVerificationEmail({
onMutate: () => {
setError(undefined);
setShowResendLink(false);
},
});
if (!startupConfig) {
return null;
}
@ -28,79 +48,102 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig }) => {
) : null;
};
const handleResendEmail = () => {
const email = getValues('email');
if (!email) {
return setShowResendLink(false);
}
resendLinkMutation.mutate({ email });
};
return (
<form
className="mt-6"
aria-label="Login form"
method="POST"
onSubmit={handleSubmit((data) => onSubmit(data))}
>
<div className="mb-2">
<div className="relative">
<input
type="text"
id="email"
autoComplete="email"
aria-label={localize('com_auth_email')}
{...register('email', {
required: localize('com_auth_email_required'),
maxLength: { value: 120, message: localize('com_auth_email_max_length') },
pattern: { value: /\S+@\S+\.\S+/, message: localize('com_auth_email_pattern') },
})}
aria-invalid={!!errors.email}
className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-gray-300 bg-transparent px-3.5 pb-3.5 pt-4 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-green-500"
placeholder=" "
/>
<label
htmlFor="email"
className="absolute start-1 top-2 z-10 origin-[0] -translate-y-4 scale-75 transform bg-white px-3 text-sm text-gray-500 duration-100 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-2 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-3 peer-focus:text-green-600 dark:bg-gray-900 dark:text-gray-400 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
<>
{showResendLink && (
<div className="mt-2 rounded-md border border-green-500 bg-green-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200">
{localize('com_auth_email_verification_resend_prompt')}
<button
type="button"
className="ml-2 text-blue-600 hover:underline"
onClick={handleResendEmail}
disabled={resendLinkMutation.isLoading}
>
{localize('com_auth_email_address')}
</label>
{localize('com_auth_email_resend_link')}
</button>
</div>
{renderError('email')}
</div>
<div className="mb-2">
<div className="relative">
<input
type="password"
id="password"
autoComplete="current-password"
aria-label={localize('com_auth_password')}
{...register('password', {
required: localize('com_auth_password_required'),
minLength: { value: 8, message: localize('com_auth_password_min_length') },
maxLength: { value: 128, message: localize('com_auth_password_max_length') },
})}
aria-invalid={!!errors.password}
className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-gray-300 bg-transparent px-3.5 pb-3.5 pt-4 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-green-500"
placeholder=" "
/>
<label
htmlFor="password"
className="absolute start-1 top-2 z-10 origin-[0] -translate-y-4 scale-75 transform bg-white px-3 text-sm text-gray-500 duration-100 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-2 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-3 peer-focus:text-green-600 dark:bg-gray-900 dark:text-gray-400 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password')}
</label>
</div>
{renderError('password')}
</div>
{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"
data-testid="login-button"
type="submit"
className="w-full transform rounded-md bg-green-500 px-4 py-3 tracking-wide text-white transition-colors duration-200 hover:bg-green-550 focus:bg-green-550 focus:outline-none disabled:cursor-not-allowed disabled:hover:bg-green-500"
>
{localize('com_auth_continue')}
</button>
</div>
</form>
<form
className="mt-6"
aria-label="Login form"
method="POST"
onSubmit={handleSubmit((data) => onSubmit(data))}
>
<div className="mb-2">
<div className="relative">
<input
type="text"
id="email"
autoComplete="email"
aria-label={localize('com_auth_email')}
{...register('email', {
required: localize('com_auth_email_required'),
maxLength: { value: 120, message: localize('com_auth_email_max_length') },
pattern: { value: /\S+@\S+\.\S+/, message: localize('com_auth_email_pattern') },
})}
aria-invalid={!!errors.email}
className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-gray-300 bg-transparent px-3.5 pb-3.5 pt-4 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-green-500"
placeholder=" "
/>
<label
htmlFor="email"
className="absolute start-1 top-2 z-10 origin-[0] -translate-y-4 scale-75 transform bg-white px-3 text-sm text-gray-500 duration-100 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-2 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-3 peer-focus:text-green-600 dark:bg-gray-900 dark:text-gray-400 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_email_address')}
</label>
</div>
{renderError('email')}
</div>
<div className="mb-2">
<div className="relative">
<input
type="password"
id="password"
autoComplete="current-password"
aria-label={localize('com_auth_password')}
{...register('password', {
required: localize('com_auth_password_required'),
minLength: { value: 8, message: localize('com_auth_password_min_length') },
maxLength: { value: 128, message: localize('com_auth_password_max_length') },
})}
aria-invalid={!!errors.password}
className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-gray-300 bg-transparent px-3.5 pb-3.5 pt-4 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-green-500"
placeholder=" "
/>
<label
htmlFor="password"
className="absolute start-1 top-2 z-10 origin-[0] -translate-y-4 scale-75 transform bg-white px-3 text-sm text-gray-500 duration-100 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-2 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-3 peer-focus:text-green-600 dark:bg-gray-900 dark:text-gray-400 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password')}
</label>
</div>
{renderError('password')}
</div>
{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"
data-testid="login-button"
type="submit"
className="w-full transform rounded-md bg-green-500 px-4 py-3 tracking-wide text-white transition-colors duration-200 hover:bg-green-550 focus:bg-green-550 focus:outline-none disabled:cursor-not-allowed disabled:hover:bg-green-500"
>
{localize('com_auth_continue')}
</button>
</div>
</form>
</>
);
};

View file

@ -13,28 +13,37 @@ const Registration: React.FC = () => {
const { startupConfig, startupConfigError, isFetching } = useOutletContext<TLoginLayoutContext>();
const {
register,
watch,
register,
handleSubmit,
formState: { errors },
} = useForm<TRegisterUser>({ mode: 'onChange' });
const [error, setError] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>('');
const registerUser = useRegisterUserMutation();
const password = watch('password');
const onRegisterUserFormSubmit = async (data: TRegisterUser) => {
try {
await registerUser.mutateAsync(data);
navigate('/c/new');
} catch (error) {
setError(true);
const [errorMessage, setErrorMessage] = useState<string>('');
const [countdown, setCountdown] = useState<number>(3);
const registerUser = useRegisterUserMutation({
onSuccess: () => {
setCountdown(3);
const timer = setInterval(() => {
setCountdown((prevCountdown) => {
if (prevCountdown <= 1) {
clearInterval(timer);
navigate('/c/new', { replace: true });
return 0;
} else {
return prevCountdown - 1;
}
});
}, 1000);
},
onError: (error: unknown) => {
if ((error as TError).response?.data?.message) {
setErrorMessage((error as TError).response?.data?.message ?? '');
}
}
};
},
});
useEffect(() => {
if (startupConfig?.registrationEnabled === false) {
@ -76,19 +85,32 @@ const Registration: React.FC = () => {
return (
<>
{error && (
{errorMessage && (
<ErrorMessage>
{localize('com_auth_error_create')} {errorMessage}
</ErrorMessage>
)}
{registerUser.isSuccess && countdown > 0 && (
<div
className="rounded-md border border-green-500 bg-green-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200"
role="alert"
>
{localize(
startupConfig?.emailEnabled
? 'com_auth_registration_success_generic'
: 'com_auth_registration_success_insecure',
) +
' ' +
localize('com_auth_email_verification_redirecting', countdown.toString())}
</div>
)}
{!startupConfigError && !isFetching && (
<>
<form
className="mt-6"
aria-label="Registration form"
method="POST"
onSubmit={handleSubmit(onRegisterUserFormSubmit)}
onSubmit={handleSubmit((data: TRegisterUser) => registerUser.mutate(data))}
>
{renderInput('name', 'com_auth_full_name', 'text', {
required: localize('com_auth_name_required'),

View file

@ -0,0 +1,134 @@
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useVerifyEmailMutation, useResendVerificationEmail } from '~/data-provider';
import { ThemeSelector } from '~/components/ui';
import { Spinner } from '~/components/svg';
import { useLocalize } from '~/hooks';
function RequestPasswordReset() {
const navigate = useNavigate();
const localize = useLocalize();
const [params] = useSearchParams();
const [countdown, setCountdown] = useState<number>(3);
const [headerText, setHeaderText] = useState<string>('');
const [showResendLink, setShowResendLink] = useState<boolean>(false);
const [verificationStatus, setVerificationStatus] = useState<boolean>(false);
const token = useMemo(() => params.get('token') || '', [params]);
const email = useMemo(() => params.get('email') || '', [params]);
const countdownRedirect = useCallback(() => {
setCountdown(3);
const timer = setInterval(() => {
setCountdown((prevCountdown) => {
if (prevCountdown <= 1) {
clearInterval(timer);
navigate('/c/new', { replace: true });
return 0;
} else {
return prevCountdown - 1;
}
});
}, 1000);
}, [navigate]);
const verifyEmailMutation = useVerifyEmailMutation({
onSuccess: () => {
setHeaderText(localize('com_auth_email_verification_success') + ' 🎉');
setVerificationStatus(true);
countdownRedirect();
},
onError: () => {
setShowResendLink(true);
setVerificationStatus(true);
setHeaderText(localize('com_auth_email_verification_failed') + ' 😢');
setCountdown(0);
},
});
const resendEmailMutation = useResendVerificationEmail({
onSuccess: () => {
setHeaderText(localize('com_auth_email_resent_success') + ' 📧');
countdownRedirect();
},
onError: () => {
setHeaderText(localize('com_auth_email_resent_failed') + ' 😢');
countdownRedirect();
},
onMutate: () => setShowResendLink(false),
});
const handleResendEmail = () => {
resendEmailMutation.mutate({ email });
};
useEffect(() => {
if (verifyEmailMutation.isLoading || verificationStatus) {
return;
}
if (token && email) {
verifyEmailMutation.mutate({
email,
token,
});
return;
} else if (email) {
setHeaderText(localize('com_auth_email_verification_failed_token_missing') + ' 😢');
} else {
setHeaderText(localize('com_auth_email_verification_invalid') + ' 🤨');
}
setShowResendLink(true);
setVerificationStatus(true);
setCountdown(0);
}, [localize, token, email, verificationStatus, verifyEmailMutation]);
const VerificationSuccess = () => (
<div className="flex flex-col items-center justify-center">
<h1 className="mb-4 text-center text-3xl font-semibold text-black dark:text-white">
{headerText}
</h1>
{countdown > 0 && (
<p className="text-center text-lg text-gray-600 dark:text-gray-400">
{localize('com_auth_email_verification_redirecting', countdown.toString())}
</p>
)}
{showResendLink && countdown === 0 && (
<p className="text-center text-lg text-gray-600 dark:text-gray-400">
{localize('com_auth_email_verification_resend_prompt')}
<button
className="ml-2 text-blue-600 hover:underline"
onClick={handleResendEmail}
disabled={resendEmailMutation.isLoading}
>
{localize('com_auth_email_resend_link')}
</button>
</p>
)}
</div>
);
const VerificationInProgress = () => (
<div className="flex flex-col items-center justify-center">
<h1 className="mb-4 text-center text-3xl font-semibold text-black dark:text-white">
{localize('com_auth_email_verification_in_progress')}
</h1>
<div className="mt-4 flex justify-center">
<Spinner className="h-8 w-8 text-green-500" />
</div>
</div>
);
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-white pt-6 dark:bg-gray-900 sm:pt-0">
<div className="absolute bottom-0 left-0 m-4">
<ThemeSelector />
</div>
{verificationStatus ? <VerificationSuccess /> : <VerificationInProgress />}
</div>
);
}
export default RequestPasswordReset;

View file

@ -1,5 +1,6 @@
export { default as Login } from './Login';
export { default as Registration } from './Registration';
export { default as ResetPassword } from './ResetPassword';
export { default as VerifyEmail } from './VerifyEmail';
export { default as ApiErrorWatcher } from './ApiErrorWatcher';
export { default as RequestPasswordReset } from './RequestPasswordReset';

View file

@ -17,7 +17,7 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
const localize = useLocalize();
const { user, logout } = useAuthContext();
const { mutate: deleteUser, isLoading: isDeleting } = useDeleteUserMutation({
onSuccess: () => logout(),
onMutate: () => logout(),
});
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
@ -76,7 +76,6 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
<ul>
<li>{localize('com_nav_delete_warning')}</li>
<li>{localize('com_nav_delete_data_info')}</li>
<li>{localize('com_nav_delete_help_center')}</li>
</ul>
</div>
<div className="flex-col items-center justify-center">

View file

@ -942,3 +942,28 @@ export const useDeleteAction = (
},
});
};
/**
* Hook for verifying email address
*/
export const useVerifyEmailMutation = (
options?: t.VerifyEmailOptions,
): UseMutationResult<t.VerifyEmailResponse, unknown, t.TVerifyEmail, unknown> => {
return useMutation({
mutationFn: (variables: t.TVerifyEmail) => dataService.verifyEmail(variables),
...(options || {}),
});
};
/**
* Hook for resending verficiation email
*/
export const useResendVerificationEmail = (
options?: t.ResendVerifcationOptions,
): UseMutationResult<t.VerifyEmailResponse, unknown, t.TResendVerificationEmail, unknown> => {
return useMutation({
mutationFn: (variables: t.TResendVerificationEmail) =>
dataService.resendVerificationEmail(variables),
...(options || {}),
});
};

View file

@ -8,7 +8,7 @@ import {
useContext,
} from 'react';
import { useRecoilState } from 'recoil';
import { TUser, TLoginResponse, setTokenHeader, TLoginUser } from 'librechat-data-provider';
import { TLoginResponse, setTokenHeader, TLoginUser } from 'librechat-data-provider';
import {
useGetUserQuery,
useLoginUserMutation,
@ -169,10 +169,11 @@ const AuthContextProvider = ({
() => ({
user,
token,
isAuthenticated,
error,
login,
logout,
setError,
isAuthenticated,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[user, error, isAuthenticated, token],

View file

@ -254,6 +254,8 @@ export default {
'Your account has been temporarily banned due to violations of our service.',
com_auth_error_login_server:
'There was an internal server error. Please wait a few moments and try again.',
com_auth_error_login_unverified:
'Your account has not been verified. Please check your email for a verification link.',
com_auth_no_account: 'Don\'t have an account?',
com_auth_sign_up: 'Sign up',
com_auth_sign_in: 'Sign in',
@ -288,6 +290,8 @@ export default {
com_auth_username_max_length: 'Username must be less than 20 characters',
com_auth_already_have_account: 'Already have an account?',
com_auth_login: 'Login',
com_auth_registration_success_insecure: 'Registration successful.',
com_auth_registration_success_generic: 'Please check your email to verify your email address.',
com_auth_reset_password: 'Reset your password',
com_auth_click: 'Click',
com_auth_here: 'HERE',
@ -305,6 +309,17 @@ export default {
com_auth_submit_registration: 'Submit registration',
com_auth_welcome_back: 'Welcome back',
com_auth_back_to_login: 'Back to Login',
com_auth_email_verification_failed: 'Email verification failed',
com_auth_email_verification_rate_limited: 'Too many requests. Please try again later',
com_auth_email_verification_success: 'Email verified successfully',
com_auth_email_resent_success: 'Verification email resent successfully',
com_auth_email_resent_failed: 'Failed to resend verification email',
com_auth_email_verification_failed_token_missing: 'Verification failed, token missing',
com_auth_email_verification_invalid: 'Invalid email verification',
com_auth_email_verification_in_progress: 'Verifying your email, please wait',
com_auth_email_verification_resend_prompt: 'Didn\'t receive the email?',
com_auth_email_resend_link: 'Resend Email',
com_auth_email_verification_redirecting: 'Redirecting in {0} seconds...',
com_endpoint_open_menu: 'Open Menu',
com_endpoint_bing_enable_sydney: 'Enable Sydney',
com_endpoint_bing_to_enable_sydney: 'To enable Sydney',
@ -554,7 +569,6 @@ export default {
com_nav_delete_account_confirm_placeholder: 'To proceed, type "DELETE" in the input field below',
com_nav_delete_warning: 'WARNING: This will permanently delete your account.',
com_nav_delete_data_info: 'All your data will be deleted.',
com_nav_delete_help_center: 'For more information, please visit our Help Center.',
com_nav_conversation_mode: 'Conversation Mode',
com_nav_auto_send_text: 'Auto send text (after 3 sec)',
com_nav_auto_transcribe_audio: 'Auto transcribe audio',

View file

@ -4,6 +4,7 @@ import {
Registration,
RequestPasswordReset,
ResetPassword,
VerifyEmail,
ApiErrorWatcher,
} from '~/components/Auth';
import { AuthContextProvider } from '~/hooks/AuthContext';
@ -44,6 +45,10 @@ export const router = createBrowserRouter([
},
],
},
{
path: 'verify',
element: <VerifyEmail />,
},
{
element: <AuthLayout />,
children: [

View file

@ -1,17 +1,21 @@
const getLoginError = (errorText: string) => {
const defaultError = 'com_auth_error_login';
if (!errorText) {
return defaultError;
}
if (errorText?.includes('429')) {
return 'com_auth_error_login_rl';
} else if (errorText?.includes('403')) {
return 'com_auth_error_login_ban';
} else if (errorText?.includes('500')) {
return 'com_auth_error_login_server';
} else {
return defaultError;
switch (true) {
case errorText.includes('429'):
return 'com_auth_error_login_rl';
case errorText.includes('403'):
return 'com_auth_error_login_ban';
case errorText.includes('500'):
return 'com_auth_error_login_server';
case errorText.includes('422'):
return 'com_auth_error_login_unverified';
default:
return defaultError;
}
};

View file

@ -83,6 +83,10 @@ export const requestPasswordReset = () => '/api/auth/requestPasswordReset';
export const resetPassword = () => '/api/auth/resetPassword';
export const verifyEmail = () => '/api/user/verify';
export const resendVerificationEmail = () => '/api/user/verify/resend';
export const plugins = () => '/api/plugins';
export const config = () => '/api/config';

View file

@ -677,6 +677,14 @@ export enum ViolationTypes {
* STT Request Limit Violation.
*/
STT_LIMIT = 'stt_limit',
/**
* Reset Password Limit Violation.
*/
RESET_PASSWORD_LIMIT = 'reset_password_limit',
/**
* Verify Email Limit Violation.
*/
VERIFY_EMAIL_LIMIT = 'verify_email_limit',
}
/**

View file

@ -142,6 +142,16 @@ export const resetPassword = (payload: t.TResetPassword) => {
return request.post(endpoints.resetPassword(), payload);
};
export const verifyEmail = (payload: t.TVerifyEmail): Promise<t.VerifyEmailResponse> => {
return request.post(endpoints.verifyEmail(), payload);
};
export const resendVerificationEmail = (
payload: t.TResendVerificationEmail,
): Promise<t.VerifyEmailResponse> => {
return request.post(endpoints.resendVerificationEmail(), payload);
};
export const getAvailablePlugins = (): Promise<s.TPlugin[]> => {
return request.get(endpoints.plugins());
};

View file

@ -322,16 +322,17 @@ export const useLoginUserMutation = (): UseMutationResult<
});
};
export const useRegisterUserMutation = (): UseMutationResult<
unknown,
unknown,
t.TRegisterUser,
unknown
> => {
export const useRegisterUserMutation = (
options?: m.RegistrationOptions,
): UseMutationResult<t.TError, unknown, t.TRegisterUser, unknown> => {
const queryClient = useQueryClient();
return useMutation((payload: t.TRegisterUser) => dataService.register(payload), {
onSuccess: () => {
...options,
onSuccess: (...args) => {
queryClient.invalidateQueries([QueryKeys.user]);
if (options?.onSuccess) {
options.onSuccess(...args);
}
},
});
};

View file

@ -215,6 +215,10 @@ export type TSearchMessage = object;
export type TSearchMessageTreeNode = object;
export type TRegisterUserResponse = {
message: string;
};
export type TRegisterUser = {
name: string;
email: string;
@ -244,6 +248,15 @@ export type TResetPassword = {
confirm_password?: string;
};
export type VerifyEmailResponse = { message: string };
export type TVerifyEmail = {
email: string;
token: string;
};
export type TResendVerificationEmail = Omit<TVerifyEmail, 'token'>;
export type TInterfaceConfig = {
privacyPolicy?: {
externalUrl?: string;

View file

@ -109,3 +109,16 @@ export type UpdateSharedLinkOptions = MutationOptions<
Partial<types.TSharedLink>
>;
export type DeleteSharedLinkOptions = MutationOptions<types.TSharedLink, { shareId: string }>;
/* Auth mutations */
export type VerifyEmailOptions = MutationOptions<types.VerifyEmailResponse, types.TVerifyEmail>;
export type ResendVerifcationOptions = MutationOptions<
types.VerifyEmailResponse,
types.TResendVerificationEmail
>;
export type RegistrationOptions = MutationOptions<
types.TRegisterUserResponse,
types.TRegisterUser,
unknown,
types.TError
>;