Compare commits
1 Commits
v0.7.3
...
bottleneck
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b76233bd66 |
15
.env.example
15
.env.example
@@ -64,8 +64,6 @@ PROXY=
|
||||
|
||||
# ANYSCALE_API_KEY=
|
||||
# APIPIE_API_KEY=
|
||||
# COHERE_API_KEY=
|
||||
# DATABRICKS_API_KEY=
|
||||
# FIREWORKS_API_KEY=
|
||||
# GROQ_API_KEY=
|
||||
# HUGGINGFACE_TOKEN=
|
||||
@@ -321,9 +319,6 @@ ALLOW_EMAIL_LOGIN=true
|
||||
ALLOW_REGISTRATION=true
|
||||
ALLOW_SOCIAL_LOGIN=false
|
||||
ALLOW_SOCIAL_REGISTRATION=false
|
||||
ALLOW_PASSWORD_RESET=false
|
||||
# ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out
|
||||
ALLOW_UNVERIFIED_EMAIL_LOGIN=true
|
||||
|
||||
SESSION_EXPIRY=1000 * 60 * 15
|
||||
REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7
|
||||
@@ -399,13 +394,6 @@ FIREBASE_STORAGE_BUCKET=
|
||||
FIREBASE_MESSAGING_SENDER_ID=
|
||||
FIREBASE_APP_ID=
|
||||
|
||||
#========================#
|
||||
# Shared Links #
|
||||
#========================#
|
||||
|
||||
ALLOW_SHARED_LINKS=true
|
||||
ALLOW_SHARED_LINKS_PUBLIC=true
|
||||
|
||||
#===================================================#
|
||||
# UI #
|
||||
#===================================================#
|
||||
@@ -416,9 +404,6 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||
|
||||
# SHOW_BIRTHDAY_ICON=true
|
||||
|
||||
# Google tag manager id
|
||||
#ANALYTICS_GTM_ID=user provided google tag manager id
|
||||
|
||||
#==================================================#
|
||||
# Others #
|
||||
#==================================================#
|
||||
|
||||
12
.github/CONTRIBUTING.md
vendored
12
.github/CONTRIBUTING.md
vendored
@@ -126,18 +126,6 @@ Apply the following naming conventions to branches, labels, and other Git-relate
|
||||
|
||||
- **Current Stance**: At present, this backend transition is of lower priority and might not be pursued.
|
||||
|
||||
## 7. Module Import Conventions
|
||||
|
||||
- `npm` packages first,
|
||||
- from shortest line (top) to longest (bottom)
|
||||
|
||||
- Followed by typescript types (pertains to data-provider and client workspaces)
|
||||
- longest line (top) to shortest (bottom)
|
||||
- types from package come first
|
||||
|
||||
- Lastly, local imports
|
||||
- longest line (top) to shortest (bottom)
|
||||
- imports with alias `~` treated the same as relative import with respect to line length
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# v0.7.3
|
||||
# v0.7.2
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# v0.7.3
|
||||
# v0.7.2
|
||||
|
||||
# Build API, Client and Data Provider
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
@@ -58,13 +58,9 @@
|
||||
- 🌎 Multilingual UI:
|
||||
- English, 中文, Deutsch, Español, Français, Italiano, Polski, Português Brasileiro,
|
||||
- Русский, 日本語, Svenska, 한국어, Tiếng Việt, 繁體中文, العربية, Türkçe, Nederlands, עברית
|
||||
- 🎨 Customizable Dropdown & Interface: Adapts to both power users and newcomers
|
||||
- 📧 Verify your email to ensure secure access
|
||||
- 🗣️ Chat hands-free with Speech-to-Text and Text-to-Speech magic
|
||||
- Automatically send and play Audio
|
||||
- Supports OpenAI, Azure OpenAI, and Elevenlabs
|
||||
- 🎨 Customizable Dropdown & Interface: Adapts to both power users and newcomers.
|
||||
- 📥 Import Conversations from LibreChat, ChatGPT, Chatbot UI
|
||||
- 📤 Export conversations as screenshots, markdown, text, json
|
||||
- 📤 Export conversations as screenshots, markdown, text, json.
|
||||
- 🔍 Search all messages/conversations
|
||||
- 🔌 Plugins, including web access, image generation with DALL-E-3 and more
|
||||
- 👥 Multi-User, Secure Authentication with Moderation and Token spend tools
|
||||
|
||||
@@ -69,9 +69,6 @@ class BaseClient {
|
||||
url = this.options.reverseProxyUrl;
|
||||
}
|
||||
logger.debug(`Making request to ${url}`);
|
||||
if (typeof Bun !== 'undefined') {
|
||||
return await fetch(url, init);
|
||||
}
|
||||
return await fetch(url, init);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ In your response, remember to follow these guidelines:
|
||||
- If you don't know the answer, simply say that you don't know.
|
||||
- If you are unsure how to answer, ask for clarification.
|
||||
- Avoid mentioning that you obtained the information from the context.
|
||||
|
||||
Answer appropriately in the user's language.
|
||||
`;
|
||||
|
||||
function createContextHandlers(req, userMessageContent) {
|
||||
@@ -92,40 +94,37 @@ function createContextHandlers(req, userMessageContent) {
|
||||
|
||||
const resolvedQueries = await Promise.all(queryPromises);
|
||||
|
||||
const context =
|
||||
resolvedQueries.length === 0
|
||||
? '\n\tThe semantic search did not return any results.'
|
||||
: resolvedQueries
|
||||
.map((queryResult, index) => {
|
||||
const file = processedFiles[index];
|
||||
let contextItems = queryResult.data;
|
||||
const context = resolvedQueries
|
||||
.map((queryResult, index) => {
|
||||
const file = processedFiles[index];
|
||||
let contextItems = queryResult.data;
|
||||
|
||||
const generateContext = (currentContext) =>
|
||||
`
|
||||
const generateContext = (currentContext) =>
|
||||
`
|
||||
<file>
|
||||
<filename>${file.filename}</filename>
|
||||
<context>${currentContext}
|
||||
</context>
|
||||
</file>`;
|
||||
|
||||
if (useFullContext) {
|
||||
return generateContext(`\n${contextItems}`);
|
||||
}
|
||||
if (useFullContext) {
|
||||
return generateContext(`\n${contextItems}`);
|
||||
}
|
||||
|
||||
contextItems = queryResult.data
|
||||
.map((item) => {
|
||||
const pageContent = item[0].page_content;
|
||||
return `
|
||||
contextItems = queryResult.data
|
||||
.map((item) => {
|
||||
const pageContent = item[0].page_content;
|
||||
return `
|
||||
<contextItem>
|
||||
<![CDATA[${pageContent?.trim()}]]>
|
||||
</contextItem>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return generateContext(contextItems);
|
||||
})
|
||||
.join('');
|
||||
|
||||
return generateContext(contextItems);
|
||||
})
|
||||
.join('');
|
||||
|
||||
if (useFullContext) {
|
||||
const prompt = `${header}
|
||||
${context}
|
||||
|
||||
4
api/cache/getLogStores.js
vendored
4
api/cache/getLogStores.js
vendored
@@ -63,10 +63,6 @@ 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,
|
||||
),
|
||||
|
||||
2
api/cache/logViolation.js
vendored
2
api/cache/logViolation.js
vendored
@@ -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.
|
||||
|
||||
@@ -97,12 +97,8 @@ const deleteFileByFilter = async (filter) => {
|
||||
* @param {Array<string>} file_ids - The unique identifiers of the files to delete.
|
||||
* @returns {Promise<Object>} A promise that resolves to the result of the deletion operation.
|
||||
*/
|
||||
const deleteFiles = async (file_ids, user) => {
|
||||
let deleteQuery = { file_id: { $in: file_ids } };
|
||||
if (user) {
|
||||
deleteQuery = { user: user };
|
||||
}
|
||||
return await File.deleteMany(deleteQuery);
|
||||
const deleteFiles = async (file_ids) => {
|
||||
return await File.deleteMany({ file_id: { $in: file_ids } });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -86,21 +86,4 @@ 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' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,61 @@
|
||||
const mongoose = require('mongoose');
|
||||
const userSchema = require('~/models/schema/userSchema');
|
||||
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 User = mongoose.model('User', userSchema);
|
||||
|
||||
|
||||
@@ -6,18 +6,9 @@ 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,
|
||||
@@ -38,14 +29,9 @@ module.exports = {
|
||||
Session,
|
||||
Balance,
|
||||
|
||||
comparePassword,
|
||||
deleteUserById,
|
||||
generateToken,
|
||||
getUserById,
|
||||
countUsers,
|
||||
createUser,
|
||||
hashPassword,
|
||||
updateUser,
|
||||
findUser,
|
||||
getUser,
|
||||
|
||||
getMessages,
|
||||
saveMessage,
|
||||
|
||||
@@ -3,9 +3,9 @@ const mongoose = require('mongoose');
|
||||
|
||||
/**
|
||||
* @typedef {Object} MongoFile
|
||||
* @property {ObjectId} [_id] - MongoDB Document ID
|
||||
* @property {mongoose.Schema.Types.ObjectId} [_id] - MongoDB Document ID
|
||||
* @property {number} [__v] - MongoDB Version Key
|
||||
* @property {ObjectId} user - User ID
|
||||
* @property {mongoose.Schema.Types.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,19 +14,17 @@ 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=0] - Number of uses of the file
|
||||
* @property {number} usage - Number of uses of the file
|
||||
* @property {string} [context] - Context of the file origin
|
||||
* @property {boolean} [embedded=false] - Whether or not the file is embedded in vector db
|
||||
* @property {boolean} [embedded] - 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 (e.g., from FileSources)
|
||||
* @property {string} [source] - The source of the file
|
||||
* @property {number} [width] - Optional width of the file
|
||||
* @property {number} [height] - Optional height of the file
|
||||
* @property {Date} [expiresAt] - Optional expiration date of the file
|
||||
* @property {Date} [expiresAt] - Optional height 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: {
|
||||
@@ -93,7 +91,7 @@ const fileSchema = mongoose.Schema(
|
||||
height: Number,
|
||||
expiresAt: {
|
||||
type: Date,
|
||||
expires: 3600, // 1 hour in seconds
|
||||
expires: 3600,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,7 +11,6 @@ const messageSchema = mongoose.Schema(
|
||||
},
|
||||
conversationId: {
|
||||
type: String,
|
||||
index: true,
|
||||
required: true,
|
||||
meiliIndex: true,
|
||||
},
|
||||
|
||||
@@ -7,9 +7,6 @@ const tokenSchema = new Schema({
|
||||
required: true,
|
||||
ref: 'user',
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
},
|
||||
token: {
|
||||
type: String,
|
||||
required: true,
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
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,
|
||||
@@ -37,7 +7,6 @@ const Session = mongoose.Schema({
|
||||
},
|
||||
});
|
||||
|
||||
/** @type {MongooseSchema<MongoUser>} */
|
||||
const userSchema = mongoose.Schema(
|
||||
{
|
||||
name: {
|
||||
@@ -117,10 +86,6 @@ const userSchema = mongoose.Schema(
|
||||
refreshToken: {
|
||||
type: [Session],
|
||||
},
|
||||
expiresAt: {
|
||||
type: Date,
|
||||
expires: 604800, // 7 days in seconds
|
||||
},
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
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.
|
||||
* @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.
|
||||
* @returns {Promise<Object>} A plain object representing the user document, or `null` if no user is found.
|
||||
*/
|
||||
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();
|
||||
const getUser = async function (userId) {
|
||||
return await User.findById(userId).lean();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -39,132 +30,17 @@ const findUser = async function (searchCriteria, fieldsToSelect = null) {
|
||||
*
|
||||
* @param {string} userId - The ID of the user to update.
|
||||
* @param {Object} updateData - An object containing the properties to update.
|
||||
* @returns {Promise<MongoUser>} The updated user document as a plain object, or `null` if no user is found.
|
||||
* @returns {Promise<Object>} The updated user document as a plain object, or `null` if no user is found.
|
||||
*/
|
||||
const updateUser = async function (userId, updateData) {
|
||||
const updateOperation = {
|
||||
$set: updateData,
|
||||
$unset: { expiresAt: '' }, // Remove the expiresAt field to prevent TTL
|
||||
};
|
||||
return await User.findByIdAndUpdate(userId, updateOperation, {
|
||||
return await User.findByIdAndUpdate(userId, updateData, {
|
||||
new: true,
|
||||
runValidators: true,
|
||||
}).lean();
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<ObjectId>} 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: disableTTL ? null : new Date(Date.now() + 604800 * 1000), // 1 week in milliseconds
|
||||
};
|
||||
|
||||
if (disableTTL) {
|
||||
delete userData.expiresAt;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.create(userData);
|
||||
return user._id;
|
||||
} 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,
|
||||
hashPassword,
|
||||
updateUser,
|
||||
findUser,
|
||||
getUser,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "0.7.3",
|
||||
"version": "0.7.2",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"start": "echo 'please run this from the root directory'",
|
||||
@@ -41,8 +41,10 @@
|
||||
"@langchain/community": "^0.0.46",
|
||||
"@langchain/google-genai": "^0.0.11",
|
||||
"@langchain/google-vertexai": "^0.0.17",
|
||||
"agenda": "^5.0.0",
|
||||
"axios": "^1.3.4",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bottleneck": "^2.19.5",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"cohere-ai": "^7.9.1",
|
||||
"connect-redis": "^7.1.0",
|
||||
|
||||
@@ -1,29 +1,45 @@
|
||||
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);
|
||||
const { status, message } = response;
|
||||
res.status(status).send({ message });
|
||||
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 });
|
||||
}
|
||||
} 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);
|
||||
const resetService = await requestPasswordReset(req.body.email);
|
||||
if (resetService instanceof Error) {
|
||||
return res.status(400).json(resetService);
|
||||
} else {
|
||||
@@ -61,7 +77,7 @@ const refreshController = async (req, res) => {
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||
const user = await getUserById(payload.id, '-password -__v');
|
||||
const user = await User.findOne({ _id: payload.id });
|
||||
if (!user) {
|
||||
return res.status(401).redirect('/login');
|
||||
}
|
||||
@@ -70,7 +86,8 @@ const refreshController = async (req, res) => {
|
||||
|
||||
if (process.env.NODE_ENV === 'CI') {
|
||||
const token = await setAuthTokens(userId, res);
|
||||
return res.status(200).send({ token, user });
|
||||
const userObj = user.toJSON();
|
||||
return res.status(200).send({ token, user: userObj });
|
||||
}
|
||||
|
||||
// Hash the refresh token
|
||||
@@ -81,7 +98,8 @@ 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);
|
||||
res.status(200).send({ token, user });
|
||||
const userObj = user.toJSON();
|
||||
res.status(200).send({ token, user: userObj });
|
||||
} else if (req?.query?.retry) {
|
||||
// Retrying from a refresh token request that failed (401)
|
||||
res.status(403).send('No session found');
|
||||
@@ -97,6 +115,7 @@ const refreshController = async (req, res) => {
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getUserController,
|
||||
refreshController,
|
||||
registrationController,
|
||||
resetPasswordController,
|
||||
|
||||
@@ -1,37 +1,11 @@
|
||||
const {
|
||||
Session,
|
||||
Balance,
|
||||
getFiles,
|
||||
deleteFiles,
|
||||
deleteConvos,
|
||||
deletePresets,
|
||||
deleteMessages,
|
||||
deleteUserById,
|
||||
} = require('~/models');
|
||||
const { updateUserPluginsService } = require('~/server/services/UserService');
|
||||
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
||||
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
|
||||
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||
const { deleteAllSharedLinks } = require('~/models/Share');
|
||||
const { Transaction } = require('~/models/Transaction');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const getUserController = async (req, res) => {
|
||||
res.status(200).send(req.user);
|
||||
};
|
||||
|
||||
const deleteUserFiles = async (req) => {
|
||||
try {
|
||||
const userFiles = await getFiles({ user: req.user.id });
|
||||
await processDeleteRequest({
|
||||
req,
|
||||
files: userFiles,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[deleteUserFiles]', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateUserPluginsController = async (req, res) => {
|
||||
const { user } = req;
|
||||
const { pluginKey, action, auth, isAssistantTool } = req.body;
|
||||
@@ -75,68 +49,11 @@ const updateUserPluginsController = async (req, res) => {
|
||||
res.status(200).send();
|
||||
} catch (err) {
|
||||
logger.error('[updateUserPluginsController]', err);
|
||||
return res.status(500).json({ message: 'Something went wrong.' });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUserController = async (req, res) => {
|
||||
const { user } = req;
|
||||
|
||||
try {
|
||||
await deleteMessages({ user: user.id }); // delete user messages
|
||||
await Session.deleteMany({ user: user.id }); // delete user sessions
|
||||
await Transaction.deleteMany({ user: user.id }); // delete user transactions
|
||||
await deleteUserKey({ userId: user.id, all: true }); // delete user keys
|
||||
await Balance.deleteMany({ user: user._id }); // delete user balances
|
||||
await deletePresets(user.id); // delete user presets
|
||||
/* TODO: Delete Assistant Threads */
|
||||
await deleteConvos(user.id); // delete user convos
|
||||
await deleteUserPluginAuth(user.id, null, true); // delete user plugin auth
|
||||
await deleteUserById(user.id); // delete user
|
||||
await deleteAllSharedLinks(user.id); // delete user shared links
|
||||
await deleteUserFiles(req); // delete user files
|
||||
await deleteFiles(null, user.id); // delete database files in case of orphaned files from previous steps
|
||||
/* 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);
|
||||
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.' });
|
||||
res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getUserController,
|
||||
deleteUserController,
|
||||
verifyEmailController,
|
||||
updateUserPluginsController,
|
||||
resendVerificationController,
|
||||
};
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
const User = require('~/models/User');
|
||||
const { setAuthTokens } = require('~/server/services/AuthService');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const loginController = async (req, res) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
const user = await User.findById(req.user._id);
|
||||
|
||||
// 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' });
|
||||
}
|
||||
|
||||
const { password: _, __v, ...user } = req.user;
|
||||
user.id = user._id.toString();
|
||||
|
||||
const token = await setAuthTokens(req.user._id, res);
|
||||
const token = await setAuthTokens(user._id, res);
|
||||
|
||||
return res.status(200).send({ token, user });
|
||||
} catch (err) {
|
||||
logger.error('[loginController]', err);
|
||||
return res.status(500).json({ message: 'Something went wrong' });
|
||||
}
|
||||
|
||||
// Generic error messages are safer
|
||||
return res.status(500).json({ message: 'Something went wrong' });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -6,16 +6,16 @@ const axios = require('axios');
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const mongoSanitize = require('express-mongo-sanitize');
|
||||
const { jwtLogin, passportLogin } = require('~/strategies');
|
||||
const { connectDb, indexSync } = require('~/lib/db');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { ldapLogin } = require('~/strategies');
|
||||
const { logger } = require('~/config');
|
||||
const validateImageRequest = require('./middleware/validateImageRequest');
|
||||
const errorController = require('./controllers/ErrorController');
|
||||
const { jwtLogin, passportLogin } = require('~/strategies');
|
||||
const configureSocialLogins = require('./socialLogins');
|
||||
const { connectDb, indexSync } = require('~/lib/db');
|
||||
const AppService = require('./services/AppService');
|
||||
const noIndex = require('./middleware/noIndex');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
const { ldapLogin } = require('~/strategies');
|
||||
const routes = require('./routes');
|
||||
|
||||
const { PORT, HOST, ALLOW_SOCIAL_LOGIN } = process.env ?? {};
|
||||
@@ -93,7 +93,7 @@ const startServer = async () => {
|
||||
app.use('/api/share', routes.share);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.sendFile(path.join(app.locals.paths.dist, 'index.html'));
|
||||
res.status(404).sendFile(path.join(app.locals.paths.dist, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(port, host, () => {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Checks if the user can delete their account
|
||||
*
|
||||
* @async
|
||||
* @function
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Next middleware function
|
||||
*
|
||||
* @returns {Promise<function|Object>} - Returns a Promise which when resolved calls next middleware if the user can delete their account
|
||||
*/
|
||||
|
||||
const canDeleteAccount = async (req, res, next = () => {}) => {
|
||||
const { user } = req;
|
||||
const { ALLOW_ACCOUNT_DELETION = true } = process.env;
|
||||
if (user?.role === 'ADMIN' || isEnabled(ALLOW_ACCOUNT_DELETION)) {
|
||||
return next();
|
||||
} else {
|
||||
logger.error(`[User] [Delete Account] [User cannot delete account] [User: ${user?.id}]`);
|
||||
return res.status(403).send({ message: 'You do not have permission to delete this account' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = canDeleteAccount;
|
||||
@@ -1,11 +1,11 @@
|
||||
const Keyv = require('keyv');
|
||||
const uap = require('ua-parser-js');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { isEnabled, removePorts } = require('~/server/utils');
|
||||
const { isEnabled, removePorts } = require('../utils');
|
||||
const keyvMongo = require('~/cache/keyvMongo');
|
||||
const denyRequest = require('./denyRequest');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { findUser } = require('~/models');
|
||||
const User = require('~/models/User');
|
||||
|
||||
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 findUser({ email: req.body.email }, '_id');
|
||||
const user = await User.findOne({ email: req.body.email }, '_id').lean();
|
||||
userId = user?._id ? user._id.toString() : userId;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +1,47 @@
|
||||
const validatePasswordReset = require('./validatePasswordReset');
|
||||
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 validateRegistration = require('./validateRegistration');
|
||||
const validateImageRequest = require('./validateImageRequest');
|
||||
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');
|
||||
const importLimiters = require('./importLimiters');
|
||||
|
||||
module.exports = {
|
||||
...uploadLimiters,
|
||||
...abortMiddleware,
|
||||
...limiters,
|
||||
noIndex,
|
||||
...messageLimiters,
|
||||
checkBan,
|
||||
uaParser,
|
||||
setHeaders,
|
||||
moderateText,
|
||||
validateModel,
|
||||
loginLimiter,
|
||||
requireJwtAuth,
|
||||
requireLdapAuth,
|
||||
registerLimiter,
|
||||
requireLocalAuth,
|
||||
canDeleteAccount,
|
||||
validateEndpoint,
|
||||
concurrentLimiter,
|
||||
checkDomainAllowed,
|
||||
validateMessageReq,
|
||||
buildEndpointOption,
|
||||
validateRegistration,
|
||||
validateImageRequest,
|
||||
validatePasswordReset,
|
||||
validateModel,
|
||||
moderateText,
|
||||
noIndex,
|
||||
...importLimiters,
|
||||
checkDomainAllowed,
|
||||
};
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
@@ -1,6 +1,6 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { removePorts } = require('~/server/utils');
|
||||
const { logViolation } = require('~/cache');
|
||||
const { logViolation } = require('../../cache');
|
||||
const { removePorts } = require('../utils');
|
||||
|
||||
const { LOGIN_WINDOW = 5, LOGIN_MAX = 7, LOGIN_VIOLATION_SCORE: score } = process.env;
|
||||
const windowMs = LOGIN_WINDOW * 60 * 1000;
|
||||
@@ -1,6 +1,6 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const denyRequest = require('~/server/middleware/denyRequest');
|
||||
const { logViolation } = require('~/cache');
|
||||
const { logViolation } = require('../../cache');
|
||||
const denyRequest = require('./denyRequest');
|
||||
|
||||
const {
|
||||
MESSAGE_IP_MAX = 40,
|
||||
@@ -1,6 +1,6 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { removePorts } = require('~/server/utils');
|
||||
const { logViolation } = require('~/cache');
|
||||
const { logViolation } = require('../../cache');
|
||||
const { removePorts } = require('../utils');
|
||||
|
||||
const { REGISTER_WINDOW = 60, REGISTER_MAX = 5, REGISTRATION_VIOLATION_SCORE: score } = process.env;
|
||||
const windowMs = REGISTER_WINDOW * 60 * 1000;
|
||||
@@ -21,13 +21,7 @@ const requireLocalAuth = (req, res, next) => {
|
||||
log({
|
||||
title: '(requireLocalAuth) Error: No user',
|
||||
});
|
||||
return res.status(404).send(info);
|
||||
}
|
||||
if (info && info.message) {
|
||||
log({
|
||||
title: '(requireLocalAuth) Error: ' + info.message,
|
||||
});
|
||||
return res.status(422).send({ message: info.message });
|
||||
return res.status(422).send(info);
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
|
||||
7
api/server/middleware/speech/index.js
Normal file
7
api/server/middleware/speech/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const createTTSLimiters = require('./ttsLimiters');
|
||||
const createSTTLimiters = require('./sttLimiters');
|
||||
|
||||
module.exports = {
|
||||
createTTSLimiters,
|
||||
createSTTLimiters,
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
function validatePasswordReset(req, res, next) {
|
||||
if (isEnabled(process.env.ALLOW_PASSWORD_RESET)) {
|
||||
next();
|
||||
} else {
|
||||
logger.warn(`Password reset attempt while not allowed. IP: ${req.ip}`);
|
||||
res.status(403).send('Password reset is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = validatePasswordReset;
|
||||
@@ -1,7 +1,6 @@
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
|
||||
function validateRegistration(req, res, next) {
|
||||
if (isEnabled(process.env.ALLOW_REGISTRATION)) {
|
||||
const setting = process.env.ALLOW_REGISTRATION?.toLowerCase();
|
||||
if (setting === 'true') {
|
||||
next();
|
||||
} else {
|
||||
res.status(403).send('Registration is not allowed.');
|
||||
|
||||
@@ -25,7 +25,6 @@ afterEach(() => {
|
||||
delete process.env.DOMAIN_SERVER;
|
||||
delete process.env.ALLOW_REGISTRATION;
|
||||
delete process.env.ALLOW_SOCIAL_LOGIN;
|
||||
delete process.env.ALLOW_PASSWORD_RESET;
|
||||
delete process.env.LDAP_URL;
|
||||
delete process.env.LDAP_BIND_DN;
|
||||
delete process.env.LDAP_BIND_CREDENTIALS;
|
||||
@@ -56,7 +55,6 @@ describe.skip('GET /', () => {
|
||||
process.env.DOMAIN_SERVER = 'http://test-server.com';
|
||||
process.env.ALLOW_REGISTRATION = 'true';
|
||||
process.env.ALLOW_SOCIAL_LOGIN = 'true';
|
||||
process.env.ALLOW_PASSWORD_RESET = 'true';
|
||||
process.env.LDAP_URL = 'Test LDAP URL';
|
||||
process.env.LDAP_BIND_DN = 'Test LDAP Bind DN';
|
||||
process.env.LDAP_BIND_CREDENTIALS = 'Test LDAP Bind Credentials';
|
||||
@@ -80,7 +78,6 @@ describe.skip('GET /', () => {
|
||||
serverDomain: 'http://test-server.com',
|
||||
emailLoginEnabled: 'true',
|
||||
registrationEnabled: 'true',
|
||||
passwordResetEnabled: 'true',
|
||||
socialLoginEnabled: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
const express = require('express');
|
||||
const {
|
||||
resetPasswordRequestController,
|
||||
resetPasswordController,
|
||||
refreshController,
|
||||
registrationController,
|
||||
resetPasswordController,
|
||||
resetPasswordRequestController,
|
||||
} = require('~/server/controllers/AuthController');
|
||||
const { loginController } = require('~/server/controllers/auth/LoginController');
|
||||
const { logoutController } = require('~/server/controllers/auth/LogoutController');
|
||||
} = require('../controllers/AuthController');
|
||||
const { loginController } = require('../controllers/auth/LoginController');
|
||||
const { logoutController } = require('../controllers/auth/LogoutController');
|
||||
const {
|
||||
checkBan,
|
||||
loginLimiter,
|
||||
requireJwtAuth,
|
||||
registerLimiter,
|
||||
requireJwtAuth,
|
||||
requireLdapAuth,
|
||||
requireLocalAuth,
|
||||
resetPasswordLimiter,
|
||||
validateRegistration,
|
||||
validatePasswordReset,
|
||||
} = require('~/server/middleware');
|
||||
} = require('../middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -34,13 +32,7 @@ router.post(
|
||||
);
|
||||
router.post('/refresh', refreshController);
|
||||
router.post('/register', registerLimiter, checkBan, validateRegistration, registrationController);
|
||||
router.post(
|
||||
'/requestPasswordReset',
|
||||
resetPasswordLimiter,
|
||||
checkBan,
|
||||
validatePasswordReset,
|
||||
resetPasswordRequestController,
|
||||
);
|
||||
router.post('/resetPassword', checkBan, validatePasswordReset, resetPasswordController);
|
||||
router.post('/requestPasswordReset', resetPasswordRequestController);
|
||||
router.post('/resetPassword', resetPasswordController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -6,15 +6,6 @@ const { logger } = require('~/config');
|
||||
const router = express.Router();
|
||||
const emailLoginEnabled =
|
||||
process.env.ALLOW_EMAIL_LOGIN === undefined || isEnabled(process.env.ALLOW_EMAIL_LOGIN);
|
||||
const passwordResetEnabled = isEnabled(process.env.ALLOW_PASSWORD_RESET);
|
||||
|
||||
const sharedLinksEnabled =
|
||||
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
|
||||
|
||||
const publicSharedLinksEnabled =
|
||||
sharedLinksEnabled &&
|
||||
(process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined ||
|
||||
isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC));
|
||||
|
||||
router.get('/', async function (req, res) {
|
||||
const isBirthday = () => {
|
||||
@@ -51,7 +42,6 @@ router.get('/', async function (req, res) {
|
||||
!!process.env.EMAIL_USERNAME &&
|
||||
!!process.env.EMAIL_PASSWORD &&
|
||||
!!process.env.EMAIL_FROM,
|
||||
passwordResetEnabled,
|
||||
checkBalance: isEnabled(process.env.CHECK_BALANCE),
|
||||
showBirthdayIcon:
|
||||
isBirthday() ||
|
||||
@@ -60,9 +50,6 @@ router.get('/', async function (req, res) {
|
||||
helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
|
||||
interface: req.app.locals.interfaceConfig,
|
||||
modelSpecs: req.app.locals.modelSpecs,
|
||||
sharedLinksEnabled,
|
||||
publicSharedLinksEnabled,
|
||||
analyticsGtmId: process.env.ANALYTICS_GTM_ID,
|
||||
};
|
||||
|
||||
if (typeof process.env.CUSTOM_FOOTER === 'string') {
|
||||
|
||||
@@ -3,11 +3,12 @@ const express = require('express');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { initializeClient } = require('~/server/services/Endpoints/assistants');
|
||||
const { getConvosByPage, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
|
||||
const { IMPORT_CONVERSATION_JOB_NAME } = require('~/server/utils/import/jobDefinition');
|
||||
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const { forkConversation } = require('~/server/utils/import/fork');
|
||||
const { importConversations } = require('~/server/utils/import');
|
||||
const { createImportLimiters } = require('~/server/middleware');
|
||||
const jobScheduler = require('~/server/utils/jobScheduler');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { sleep } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
@@ -128,9 +129,10 @@ router.post(
|
||||
upload.single('file'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
/* TODO: optimize to return imported conversations and add manually */
|
||||
await importConversations({ filepath: req.file.path, requestUserId: req.user.id });
|
||||
res.status(201).json({ message: 'Conversation(s) imported successfully' });
|
||||
const filepath = req.file.path;
|
||||
const job = await jobScheduler.now(IMPORT_CONVERSATION_JOB_NAME, filepath, req.user.id);
|
||||
|
||||
res.status(201).json({ message: 'Import started', jobId: job.id });
|
||||
} catch (error) {
|
||||
logger.error('Error processing file', error);
|
||||
res.status(500).send('Error processing file');
|
||||
@@ -167,4 +169,24 @@ router.post('/fork', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get the status of an import job for polling
|
||||
router.get('/import/jobs/:jobId', async (req, res) => {
|
||||
try {
|
||||
const { jobId } = req.params;
|
||||
const { userId, ...jobStatus } = await jobScheduler.getJobStatus(jobId);
|
||||
if (!jobStatus) {
|
||||
return res.status(404).json({ message: 'Job not found.' });
|
||||
}
|
||||
|
||||
if (userId !== req.user.id) {
|
||||
return res.status(403).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
res.json(jobStatus);
|
||||
} catch (error) {
|
||||
logger.error('Error getting job details', error);
|
||||
res.status(500).send('Error getting job details');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
const express = require('express');
|
||||
const {
|
||||
uaParser,
|
||||
checkBan,
|
||||
requireJwtAuth,
|
||||
createFileLimiters,
|
||||
createTTSLimiters,
|
||||
createSTTLimiters,
|
||||
} = require('~/server/middleware');
|
||||
const { uaParser, checkBan, requireJwtAuth, createFileLimiters } = require('~/server/middleware');
|
||||
const { createTTSLimiters, createSTTLimiters } = require('~/server/middleware/speech');
|
||||
const { createMulterInstance } = require('./multer');
|
||||
|
||||
const files = require('./files');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const multer = require('multer');
|
||||
const express = require('express');
|
||||
const Bottleneck = require('bottleneck');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { getVoices, streamAudio, textToSpeech } = require('~/server/services/Files/Audio');
|
||||
const { getLogStores } = require('~/cache');
|
||||
@@ -8,8 +9,25 @@ const { logger } = require('~/config');
|
||||
const router = express.Router();
|
||||
const upload = multer();
|
||||
|
||||
// todo: can add Redis support for limiter
|
||||
const limiter = new Bottleneck({
|
||||
minTime: 240, // Minimum time between requests (240ms per request = 250 requests per minute)
|
||||
maxConcurrent: 100, // Maximum number of concurrent requests
|
||||
reservoir: 250, // Initial number of available requests
|
||||
reservoirRefreshAmount: 250, // Number of requests replenished in each interval
|
||||
reservoirRefreshInterval: 60 * 1000, // Reservoir refresh interval (60 seconds)
|
||||
});
|
||||
|
||||
const limitedStreamAudio = limiter.wrap(streamAudio);
|
||||
const limitedTextToSpeech = limiter.wrap(textToSpeech);
|
||||
|
||||
router.post('/manual', upload.none(), async (req, res) => {
|
||||
await textToSpeech(req, res);
|
||||
try {
|
||||
await limitedTextToSpeech(req, res);
|
||||
} catch (error) {
|
||||
logger.error(`[textToSpeech] user: ${req.user.id} | Failed to process textToSpeech: ${error}`);
|
||||
res.status(500).json({ error: 'Failed to process textToSpeech' });
|
||||
}
|
||||
});
|
||||
|
||||
const logDebugMessage = (req, message) =>
|
||||
@@ -26,7 +44,7 @@ router.post('/', async (req, res) => {
|
||||
return res.status(401).json({ error: 'Audio stream already running' });
|
||||
}
|
||||
audioRunsCache.set(req.body.runId, true);
|
||||
await streamAudio(req, res);
|
||||
await limitedStreamAudio(req, res);
|
||||
logDebugMessage(req, 'end stream audio');
|
||||
res.status(200).end();
|
||||
} catch (error) {
|
||||
@@ -35,6 +53,7 @@ router.post('/', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// todo: cache voices
|
||||
router.get('/voices', async (req, res) => {
|
||||
await getVoices(req, res);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const { loginLimiter, checkBan, checkDomainAllowed } = require('~/server/middleware');
|
||||
const { setAuthTokens } = require('~/server/services/AuthService');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const passport = require('passport');
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { setAuthTokens } = require('~/server/services/AuthService');
|
||||
const { loginLimiter, checkBan, checkDomainAllowed } = require('~/server/middleware');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const domains = {
|
||||
client: process.env.DOMAIN_CLIENT,
|
||||
|
||||
@@ -8,33 +8,21 @@ const {
|
||||
deleteSharedLink,
|
||||
} = require('~/models/Share');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Shared messages
|
||||
* this route does not require authentication
|
||||
*/
|
||||
const allowSharedLinks =
|
||||
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
|
||||
router.get('/:shareId', async (req, res) => {
|
||||
const share = await getSharedMessages(req.params.shareId);
|
||||
|
||||
if (allowSharedLinks) {
|
||||
const allowSharedLinksPublic =
|
||||
process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined ||
|
||||
isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
|
||||
router.get(
|
||||
'/:shareId',
|
||||
allowSharedLinksPublic ? (req, res, next) => next() : requireJwtAuth,
|
||||
async (req, res) => {
|
||||
const share = await getSharedMessages(req.params.shareId);
|
||||
|
||||
if (share) {
|
||||
res.status(200).json(share);
|
||||
} else {
|
||||
res.status(404).end();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if (share) {
|
||||
res.status(200).json(share);
|
||||
} else {
|
||||
res.status(404).end();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Shared links
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
const express = require('express');
|
||||
const { requireJwtAuth, canDeleteAccount, verifyEmailLimiter } = require('~/server/middleware');
|
||||
const {
|
||||
getUserController,
|
||||
deleteUserController,
|
||||
verifyEmailController,
|
||||
updateUserPluginsController,
|
||||
resendVerificationController,
|
||||
} = require('~/server/controllers/UserController');
|
||||
const requireJwtAuth = require('../middleware/requireJwtAuth');
|
||||
const { getUserController, updateUserPluginsController } = require('../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;
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { errorsToString } = require('librechat-data-provider');
|
||||
const {
|
||||
findUser,
|
||||
countUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
getUserById,
|
||||
generateToken,
|
||||
deleteUserById,
|
||||
} = 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,
|
||||
@@ -23,7 +15,6 @@ const domains = {
|
||||
};
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const genericVerificationMessage = 'Please check your email to verify your email address.';
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
@@ -54,72 +45,10 @@ const logoutUser = async (userId, refreshToken) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
const verificationLink = `${domains.client}/verify?token=${verifyToken}&email=${user.email}`;
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
subject: 'Verify your email',
|
||||
payload: {
|
||||
appName: process.env.APP_TITLE || 'LibreChat',
|
||||
name: user.name,
|
||||
verificationLink: verificationLink,
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
template: 'verifyEmail.handlebars',
|
||||
});
|
||||
|
||||
await new Token({
|
||||
userId: user._id,
|
||||
email: user.email,
|
||||
token: hash,
|
||||
createdAt: Date.now(),
|
||||
}).save();
|
||||
|
||||
logger.info(`[sendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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}>}
|
||||
* Register a new user
|
||||
*
|
||||
* @param {Object} user <email, password, name, username>
|
||||
* @returns
|
||||
*/
|
||||
const registerUser = async (user) => {
|
||||
const { error } = registerSchema.safeParse(user);
|
||||
@@ -131,14 +60,13 @@ const registerUser = async (user) => {
|
||||
{ name: 'Validation error:', value: errorMessage },
|
||||
);
|
||||
|
||||
return { status: 404, message: errorMessage };
|
||||
return { status: 422, message: errorMessage };
|
||||
}
|
||||
|
||||
const { email, password, name, username } = user;
|
||||
|
||||
let newUserId;
|
||||
try {
|
||||
const existingUser = await findUser({ email }, 'email _id');
|
||||
const existingUser = await User.findOne({ email }).lean();
|
||||
|
||||
if (existingUser) {
|
||||
logger.info(
|
||||
@@ -149,71 +77,51 @@ const registerUser = async (user) => {
|
||||
|
||||
// Sleep for 1 second
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return { status: 200, message: genericVerificationMessage };
|
||||
|
||||
// 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' };
|
||||
}
|
||||
|
||||
if (!(await isDomainAllowed(email))) {
|
||||
const errorMessage =
|
||||
'The email address provided cannot be used. Please use a different email address.';
|
||||
const errorMessage = 'Registration from this domain is not allowed.';
|
||||
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 countUsers()) === 0;
|
||||
const isFirstRegisteredUser = (await User.countDocuments({})) === 0;
|
||||
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const newUserData = {
|
||||
const newUser = await new User({
|
||||
provider: 'local',
|
||||
email,
|
||||
password,
|
||||
username,
|
||||
name,
|
||||
avatar: null,
|
||||
role: isFirstRegisteredUser ? 'ADMIN' : 'USER',
|
||||
password: bcrypt.hashSync(password, salt),
|
||||
};
|
||||
});
|
||||
|
||||
const emailEnabled = checkEmailConfig();
|
||||
newUserId = await createUser(newUserData, false);
|
||||
if (emailEnabled) {
|
||||
await sendVerificationEmail({
|
||||
_id: newUserId,
|
||||
email,
|
||||
name,
|
||||
});
|
||||
} else {
|
||||
await updateUser(newUserId, { emailVerified: true });
|
||||
}
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const hash = bcrypt.hashSync(newUser.password, salt);
|
||||
newUser.password = hash;
|
||||
await newUser.save();
|
||||
|
||||
return { status: 200, message: genericVerificationMessage };
|
||||
return { status: 200, user: newUser };
|
||||
} catch (err) {
|
||||
logger.error('[registerUser] Error in registering user:', err);
|
||||
if (newUserId) {
|
||||
const result = await deleteUserById(newUserId);
|
||||
logger.warn(
|
||||
`[registerUser] [Email: ${email}] [Temporary User deleted: ${JSON.stringify(result)}]`,
|
||||
);
|
||||
}
|
||||
return { status: 500, message: 'Something went wrong' };
|
||||
return { status: 500, message: err?.message || 'Something went wrong' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Request password reset
|
||||
* @param {Express.Request} req
|
||||
*
|
||||
* @param {String} email
|
||||
* @returns
|
||||
*/
|
||||
const requestPasswordReset = async (req) => {
|
||||
const { email } = req.body;
|
||||
const user = await findUser({ email }, 'email _id');
|
||||
const emailEnabled = checkEmailConfig();
|
||||
|
||||
logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`);
|
||||
|
||||
const requestPasswordReset = async (email) => {
|
||||
const user = await User.findOne({ email }).lean();
|
||||
if (!user) {
|
||||
logger.warn(`[requestPasswordReset] [No user found] [Email: ${email}] [IP: ${req.ip}]`);
|
||||
return {
|
||||
message: 'If an account with that email exists, a password reset link has been sent to it.',
|
||||
};
|
||||
return new Error('Email does not exist');
|
||||
}
|
||||
|
||||
let token = await Token.findOne({ userId: user._id });
|
||||
@@ -232,31 +140,28 @@ const requestPasswordReset = async (req) => {
|
||||
|
||||
const link = `${domains.client}/reset-password?token=${resetToken}&userId=${user._id}`;
|
||||
|
||||
const emailEnabled =
|
||||
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
|
||||
!!process.env.EMAIL_USERNAME &&
|
||||
!!process.env.EMAIL_PASSWORD &&
|
||||
!!process.env.EMAIL_FROM;
|
||||
|
||||
if (emailEnabled) {
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
subject: 'Password Reset Request',
|
||||
payload: {
|
||||
sendEmail(
|
||||
user.email,
|
||||
'Password Reset Request',
|
||||
{
|
||||
appName: process.env.APP_TITLE || 'LibreChat',
|
||||
name: user.name,
|
||||
link: link,
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
template: 'requestPasswordReset.handlebars',
|
||||
});
|
||||
logger.info(
|
||||
`[requestPasswordReset] Link emailed. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
|
||||
'requestPasswordReset.handlebars',
|
||||
);
|
||||
return { link: '' };
|
||||
} else {
|
||||
logger.info(
|
||||
`[requestPasswordReset] Link issued. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
|
||||
);
|
||||
return { link };
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'If an account with that email exists, a password reset link has been sent to it.',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -281,38 +186,39 @@ const resetPassword = async (userId, token, password) => {
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
const user = await updateUser(userId, { password: hash });
|
||||
|
||||
if (checkEmailConfig()) {
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
subject: 'Password Reset Successfully',
|
||||
payload: {
|
||||
appName: process.env.APP_TITLE || 'LibreChat',
|
||||
name: user.name,
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
template: 'passwordReset.handlebars',
|
||||
});
|
||||
}
|
||||
await User.updateOne({ _id: userId }, { $set: { password: hash } }, { new: true });
|
||||
|
||||
const user = await User.findById({ _id: userId });
|
||||
|
||||
sendEmail(
|
||||
user.email,
|
||||
'Password Reset Successfully',
|
||||
{
|
||||
appName: process.env.APP_TITLE || 'LibreChat',
|
||||
name: user.name,
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
'passwordReset.handlebars',
|
||||
);
|
||||
|
||||
await passwordResetToken.deleteOne();
|
||||
logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
|
||||
|
||||
return { message: 'Password reset was successful' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Set Auth Tokens
|
||||
*
|
||||
* @param {String | ObjectId} userId
|
||||
* @param {String} userId
|
||||
* @param {Object} res
|
||||
* @param {String} sessionId
|
||||
* @returns
|
||||
*/
|
||||
const setAuthTokens = async (userId, res, sessionId = null) => {
|
||||
try {
|
||||
const user = await getUserById(userId);
|
||||
const token = await generateToken(user);
|
||||
const user = await User.findOne({ _id: userId });
|
||||
const token = await user.generateToken();
|
||||
|
||||
let session;
|
||||
let refreshTokenExpires;
|
||||
@@ -342,70 +248,11 @@ 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);
|
||||
|
||||
const verificationLink = `${domains.client}/verify?token=${verifyToken}&email=${user.email}`;
|
||||
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
subject: 'Verify your email',
|
||||
payload: {
|
||||
appName: process.env.APP_TITLE || 'LibreChat',
|
||||
name: user.name,
|
||||
verificationLink: verificationLink,
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
template: 'verifyEmail.handlebars',
|
||||
});
|
||||
|
||||
await new Token({
|
||||
userId: user._id,
|
||||
email: user.email,
|
||||
token: hash,
|
||||
createdAt: Date.now(),
|
||||
}).save();
|
||||
|
||||
logger.info(`[resendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
|
||||
|
||||
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 = {
|
||||
logoutUser,
|
||||
verifyEmail,
|
||||
registerUser,
|
||||
setAuthTokens,
|
||||
resetPassword,
|
||||
logoutUser,
|
||||
isDomainAllowed,
|
||||
requestPasswordReset,
|
||||
resendVerificationEmail,
|
||||
resetPassword,
|
||||
setAuthTokens,
|
||||
};
|
||||
|
||||
@@ -88,17 +88,7 @@ const updateUserPluginAuth = async (userId, authField, pluginKey, value) => {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUserPluginAuth = async (userId, authField, all = false) => {
|
||||
if (all) {
|
||||
try {
|
||||
const response = await PluginAuth.deleteMany({ userId });
|
||||
return response;
|
||||
} catch (err) {
|
||||
logger.error('[deleteUserPluginAuth]', err);
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
const deleteUserPluginAuth = async (userId, authField) => {
|
||||
try {
|
||||
return await PluginAuth.deleteOne({ userId, authField });
|
||||
} catch (err) {
|
||||
|
||||
@@ -497,34 +497,6 @@ class StreamRunManager {
|
||||
return `${stepId}_tool_call_${toolCall.index}_${toolCall.type}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Missing Outputs
|
||||
* @param {ToolOutput[]} tool_outputs - The tool outputs.
|
||||
* @param {RequiredAction[]} actions - The required actions.
|
||||
* @returns {ToolOutput[]} completeOutputs - The complete outputs.
|
||||
*/
|
||||
checkMissingOutputs(tool_outputs, actions) {
|
||||
const missingOutputs = [];
|
||||
|
||||
for (const item of actions) {
|
||||
const { tool, toolCallId, run_id, thread_id } = item;
|
||||
const outputExists = tool_outputs.some((output) => output.tool_call_id === toolCallId);
|
||||
|
||||
if (!outputExists) {
|
||||
logger.warn(
|
||||
`The "${tool}" tool (ID: ${toolCallId}) failed to produce an output. run_id: ${run_id} thread_id: ${thread_id}`,
|
||||
);
|
||||
missingOutputs.push({
|
||||
tool_call_id: toolCallId,
|
||||
output:
|
||||
'The tool failed to produce an output. The tool may not be currently available or experienced an unhandled error.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...tool_outputs, ...missingOutputs];
|
||||
}
|
||||
|
||||
/* <------------------ Run Event handlers ------------------> */
|
||||
|
||||
/**
|
||||
@@ -547,8 +519,7 @@ class StreamRunManager {
|
||||
};
|
||||
});
|
||||
|
||||
const { tool_outputs: preliminaryOutputs } = await processRequiredActions(this, actions);
|
||||
const tool_outputs = this.checkMissingOutputs(preliminaryOutputs, actions);
|
||||
const { tool_outputs } = await processRequiredActions(this, actions);
|
||||
/** @type {AssistantStream | undefined} */
|
||||
let toolRun;
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { encrypt, decrypt } = require('~/server/utils');
|
||||
const { updateUser, Key } = require('~/models');
|
||||
const { User, Key } = require('~/models');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
@@ -16,13 +16,16 @@ const { logger } = require('~/config');
|
||||
*/
|
||||
const updateUserPluginsService = async (user, pluginKey, action) => {
|
||||
try {
|
||||
const userPlugins = user.plugins || [];
|
||||
if (action === 'install') {
|
||||
return await updateUser(user._id, { plugins: [...userPlugins, pluginKey] });
|
||||
return await User.updateOne(
|
||||
{ _id: user._id },
|
||||
{ $set: { plugins: [...user.plugins, pluginKey] } },
|
||||
);
|
||||
} else if (action === 'uninstall') {
|
||||
return await updateUser(user._id, {
|
||||
plugins: userPlugins.filter((plugin) => plugin !== pluginKey),
|
||||
});
|
||||
return await User.updateOne(
|
||||
{ _id: user._id },
|
||||
{ $set: { plugins: user.plugins.filter((plugin) => plugin !== pluginKey) } },
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[updateUserPluginsService]', err);
|
||||
@@ -164,11 +167,11 @@ const checkUserKeyExpiry = (expiresAt, endpoint) => {
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
updateUserPluginsService,
|
||||
getUserKey,
|
||||
updateUserKey,
|
||||
deleteUserKey,
|
||||
getUserKeyValues,
|
||||
getUserKeyExpiry,
|
||||
updateUserKey,
|
||||
deleteUserKey,
|
||||
checkUserKeyExpiry,
|
||||
updateUserPluginsService,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ const {
|
||||
deprecatedAzureVariables,
|
||||
conflictingAzureVariables,
|
||||
} = require('librechat-data-provider');
|
||||
const { isEnabled, checkEmailConfig } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const secretDefaults = {
|
||||
@@ -50,8 +49,6 @@ function checkVariables() {
|
||||
Please use the config (\`librechat.yaml\`) file for setting up OpenRouter, and use \`OPENROUTER_KEY\` or another environment variable instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
checkPasswordReset();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,25 +107,4 @@ Latest version: ${Constants.CONFIG_VERSION}
|
||||
}
|
||||
}
|
||||
|
||||
function checkPasswordReset() {
|
||||
const emailEnabled = checkEmailConfig();
|
||||
const passwordResetAllowed = isEnabled(process.env.ALLOW_PASSWORD_RESET);
|
||||
|
||||
if (!emailEnabled && passwordResetAllowed) {
|
||||
logger.warn(
|
||||
`❗❗❗
|
||||
|
||||
Password reset is enabled with \`ALLOW_PASSWORD_RESET\` but email service is not configured.
|
||||
|
||||
This setup is insecure as password reset links will be issued with a recognized email.
|
||||
|
||||
Please configure email service for secure password reset functionality.
|
||||
|
||||
https://www.librechat.ai/docs/configuration/authentication/password_reset
|
||||
|
||||
❗❗❗`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkVariables, checkHealth, checkConfig, checkAzureVariables };
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<html
|
||||
xmlns='http://www.w3.org/1999/xhtml'
|
||||
xmlns:v='urn:schemas-microsoft-com:vml'
|
||||
xmlns:o='urn:schemas-microsoft-com:office:office'
|
||||
>
|
||||
<!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">
|
||||
|
||||
<head>
|
||||
<!--[if gte mso 9]>
|
||||
<head>
|
||||
<!--[if gte mso 9]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
@@ -13,184 +11,176 @@
|
||||
</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>
|
||||
@@ -1,11 +1,9 @@
|
||||
<html
|
||||
xmlns='http://www.w3.org/1999/xhtml'
|
||||
xmlns:v='urn:schemas-microsoft-com:vml'
|
||||
xmlns:o='urn:schemas-microsoft-com:office:office'
|
||||
>
|
||||
<!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">
|
||||
|
||||
<head>
|
||||
<!--[if gte mso 9]>
|
||||
<head>
|
||||
<!--[if gte mso 9]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
@@ -13,272 +11,229 @@
|
||||
</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>
|
||||
@@ -1,11 +1,9 @@
|
||||
<html
|
||||
xmlns='http://www.w3.org/1999/xhtml'
|
||||
xmlns:v='urn:schemas-microsoft-com:vml'
|
||||
xmlns:o='urn:schemas-microsoft-com:office:office'
|
||||
>
|
||||
<!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">
|
||||
|
||||
<head>
|
||||
<!--[if gte mso 9]>
|
||||
<head>
|
||||
<!--[if gte mso 9]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
@@ -13,278 +11,229 @@
|
||||
</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="{{verificationLink}}"" 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>
|
||||
<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="{{verificationLink}}"" 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="{{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>
|
||||
@@ -135,7 +135,7 @@ async function importLibreChatConvo(
|
||||
});
|
||||
}
|
||||
|
||||
if (!firstMessageDate && message.createdAt) {
|
||||
if (!firstMessageDate) {
|
||||
firstMessageDate = new Date(message.createdAt);
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ async function importLibreChatConvo(
|
||||
const idMapping = new Map();
|
||||
|
||||
for (const message of messagesToImport) {
|
||||
if (!firstMessageDate && message.createdAt) {
|
||||
if (!firstMessageDate) {
|
||||
firstMessageDate = new Date(message.createdAt);
|
||||
}
|
||||
const newMessageId = uuidv4();
|
||||
@@ -171,10 +171,6 @@ 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`);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
const importers = require('./importers');
|
||||
const importConversations = require('./importConversations');
|
||||
|
||||
module.exports = {
|
||||
...importers,
|
||||
importConversations,
|
||||
};
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
const fs = require('fs').promises;
|
||||
const jobScheduler = require('~/server/utils/jobScheduler');
|
||||
const { getImporter } = require('./importers');
|
||||
const { indexSync } = require('~/lib/db');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const IMPORT_CONVERSATION_JOB_NAME = 'import conversation';
|
||||
|
||||
/**
|
||||
* Job definition for importing a conversation.
|
||||
* @param {{ filepath, requestUserId }} job - The job object.
|
||||
* @param {import('agenda').Job} job - The job object.
|
||||
* @param {Function} done - The done function.
|
||||
*/
|
||||
const importConversations = async (job) => {
|
||||
const { filepath, requestUserId } = job;
|
||||
const importConversationJob = async (job, done) => {
|
||||
const { filepath, requestUserId } = job.attrs.data;
|
||||
try {
|
||||
logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`);
|
||||
const fileData = await fs.readFile(filepath, 'utf8');
|
||||
@@ -18,8 +22,10 @@ const importConversations = async (job) => {
|
||||
// Sync Meilisearch index
|
||||
await indexSync();
|
||||
logger.debug(`user: ${requestUserId} | Finished importing conversations`);
|
||||
done();
|
||||
} catch (error) {
|
||||
logger.error(`user: ${requestUserId} | Failed to import conversation: `, error);
|
||||
done(error);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(filepath);
|
||||
@@ -29,4 +35,7 @@ const importConversations = async (job) => {
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = importConversations;
|
||||
// Call the jobScheduler.define function at startup
|
||||
jobScheduler.define(IMPORT_CONVERSATION_JOB_NAME, importConversationJob);
|
||||
|
||||
module.exports = { IMPORT_CONVERSATION_JOB_NAME };
|
||||
@@ -2,29 +2,15 @@ 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,
|
||||
|
||||
99
api/server/utils/jobScheduler.js
Normal file
99
api/server/utils/jobScheduler.js
Normal file
@@ -0,0 +1,99 @@
|
||||
const Agenda = require('agenda');
|
||||
const { logger } = require('~/config');
|
||||
const mongodb = require('mongodb');
|
||||
|
||||
/**
|
||||
* Class for scheduling and running jobs.
|
||||
* The workflow is as follows: start the job scheduler, define a job, and then schedule the job using defined job name.
|
||||
*/
|
||||
class JobScheduler {
|
||||
constructor() {
|
||||
this.agenda = new Agenda({ db: { address: process.env.MONGO_URI } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the job scheduler.
|
||||
*/
|
||||
async start() {
|
||||
try {
|
||||
logger.info('Starting Agenda...');
|
||||
await this.agenda.start();
|
||||
logger.info('Agenda successfully started and connected to MongoDB.');
|
||||
} catch (error) {
|
||||
logger.error('Failed to start Agenda:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a job to start immediately.
|
||||
* @param {string} jobName - The name of the job to schedule.
|
||||
* @param {string} filepath - The filepath to pass to the job.
|
||||
* @param {string} userId - The ID of the user requesting the job.
|
||||
* @returns {Promise<{ id: string }>} - A promise that resolves with the ID of the scheduled job.
|
||||
* @throws {Error} - If the job fails to schedule.
|
||||
*/
|
||||
async now(jobName, filepath, userId) {
|
||||
try {
|
||||
const job = await this.agenda.now(jobName, { filepath, requestUserId: userId });
|
||||
logger.debug(`Job '${job.attrs.name}' scheduled successfully.`);
|
||||
return { id: job.attrs._id.toString() };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to schedule job '${jobName}': ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the status of a job.
|
||||
* @param {string} jobId - The ID of the job to get the status of.
|
||||
* @returns {Promise<{ id: string, userId: string, name: string, failReason: string, status: string } | null>} - A promise that resolves with the job status or null if the job is not found.
|
||||
* @throws {Error} - If multiple jobs are found.
|
||||
*/
|
||||
async getJobStatus(jobId) {
|
||||
const job = await this.agenda.jobs({ _id: new mongodb.ObjectId(jobId) });
|
||||
if (!job || job.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (job.length > 1) {
|
||||
// This should never happen
|
||||
throw new Error('Multiple jobs found.');
|
||||
}
|
||||
|
||||
const jobDetails = {
|
||||
id: job[0]._id,
|
||||
userId: job[0].attrs.data.requestUserId,
|
||||
name: job[0].attrs.name,
|
||||
failReason: job[0].attrs.failReason,
|
||||
status: !job[0].attrs.lastRunAt
|
||||
? 'scheduled'
|
||||
: job[0].attrs.failedAt
|
||||
? 'failed'
|
||||
: job[0].attrs.lastFinishedAt
|
||||
? 'completed'
|
||||
: 'running',
|
||||
};
|
||||
|
||||
return jobDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a new job.
|
||||
* @param {string} name - The name of the job.
|
||||
* @param {Function} jobFunction - The function to run when the job is executed.
|
||||
*/
|
||||
define(name, jobFunction) {
|
||||
this.agenda.define(name, async (job, done) => {
|
||||
try {
|
||||
await jobFunction(job, done);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to run job '${name}': ${error}`);
|
||||
done(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const jobScheduler = new JobScheduler();
|
||||
jobScheduler.start();
|
||||
|
||||
module.exports = jobScheduler;
|
||||
@@ -5,34 +5,7 @@ const handlebars = require('handlebars');
|
||||
const { isEnabled } = require('~/server/utils/handleText');
|
||||
const logger = require('~/config/winston');
|
||||
|
||||
/**
|
||||
* Sends an email using the specified template, subject, and payload.
|
||||
*
|
||||
* @async
|
||||
* @function sendEmail
|
||||
* @param {Object} params - The parameters for sending the email.
|
||||
* @param {string} params.email - The recipient's email address.
|
||||
* @param {string} params.subject - The subject of the email.
|
||||
* @param {Record<string, string>} params.payload - The data to be used in the email template.
|
||||
* @param {string} params.template - The filename of the email template.
|
||||
* @param {boolean} [throwError=true] - Whether to throw an error if the email sending process fails.
|
||||
* @returns {Promise<Object>} - A promise that resolves to the info object of the sent email or the error if sending the email fails.
|
||||
*
|
||||
* @example
|
||||
* const emailData = {
|
||||
* email: 'recipient@example.com',
|
||||
* subject: 'Welcome!',
|
||||
* payload: { name: 'Recipient' },
|
||||
* template: 'welcome.html'
|
||||
* };
|
||||
*
|
||||
* sendEmail(emailData)
|
||||
* .then(info => console.log('Email sent:', info))
|
||||
* .catch(error => console.error('Error sending email:', error));
|
||||
*
|
||||
* @throws Will throw an error if the email sending process fails and throwError is `true`.
|
||||
*/
|
||||
const sendEmail = async ({ email, subject, payload, template, throwError = true }) => {
|
||||
const sendEmail = async (email, subject, payload, template) => {
|
||||
try {
|
||||
const transporterOptions = {
|
||||
// Use STARTTLS by default instead of obligatory TLS
|
||||
@@ -85,11 +58,16 @@ const sendEmail = async ({ email, subject, payload, template, throwError = true
|
||||
};
|
||||
|
||||
// Send email
|
||||
return await transporter.sendMail(options());
|
||||
transporter.sendMail(options(), (error, info) => {
|
||||
if (error) {
|
||||
logger.error('[sendEmail]', error);
|
||||
return error;
|
||||
} else {
|
||||
logger.debug('[sendEmail]', info);
|
||||
return info;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (throwError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error('[sendEmail]', error);
|
||||
return error;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,50 @@
|
||||
const { Strategy: DiscordStrategy } = require('passport-discord');
|
||||
const socialLogin = require('./socialLogin');
|
||||
const { createNewUser, handleExistingUser } = require('./process');
|
||||
const { logger } = require('~/config');
|
||||
const User = require('~/models/User');
|
||||
|
||||
const getProfileDetails = (profile) => {
|
||||
let avatarUrl;
|
||||
if (profile.avatar) {
|
||||
const format = profile.avatar.startsWith('a_') ? 'gif' : 'png';
|
||||
avatarUrl = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`;
|
||||
} else {
|
||||
const defaultAvatarNum = Number(profile.discriminator) % 5;
|
||||
avatarUrl = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNum}.png`;
|
||||
const discordLogin = async (accessToken, refreshToken, profile, cb) => {
|
||||
try {
|
||||
const email = profile.email;
|
||||
const discordId = profile.id;
|
||||
|
||||
// TODO: remove direct access of User model
|
||||
const oldUser = await User.findOne({ email });
|
||||
const ALLOW_SOCIAL_REGISTRATION =
|
||||
process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true';
|
||||
let avatarUrl;
|
||||
|
||||
if (profile.avatar) {
|
||||
const format = profile.avatar.startsWith('a_') ? 'gif' : 'png';
|
||||
avatarUrl = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`;
|
||||
} else {
|
||||
const defaultAvatarNum = Number(profile.discriminator) % 5;
|
||||
avatarUrl = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNum}.png`;
|
||||
}
|
||||
|
||||
if (oldUser) {
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
return cb(null, oldUser);
|
||||
}
|
||||
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await createNewUser({
|
||||
email,
|
||||
avatarUrl,
|
||||
provider: 'discord',
|
||||
providerKey: 'discordId',
|
||||
providerId: discordId,
|
||||
username: profile.username,
|
||||
name: profile.global_name,
|
||||
});
|
||||
return cb(null, newUser);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[discordLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
return {
|
||||
email: profile.email,
|
||||
id: profile.id,
|
||||
avatarUrl,
|
||||
username: profile.username,
|
||||
name: profile.global_name,
|
||||
emailVerified: true,
|
||||
};
|
||||
};
|
||||
|
||||
const discordLogin = socialLogin('discord', getProfileDetails);
|
||||
|
||||
module.exports = () =>
|
||||
new DiscordStrategy(
|
||||
{
|
||||
|
||||
@@ -1,16 +1,39 @@
|
||||
const FacebookStrategy = require('passport-facebook').Strategy;
|
||||
const socialLogin = require('./socialLogin');
|
||||
const { createNewUser, handleExistingUser } = require('./process');
|
||||
const { logger } = require('~/config');
|
||||
const User = require('~/models/User');
|
||||
|
||||
const getProfileDetails = (profile) => ({
|
||||
email: profile.emails[0]?.value,
|
||||
id: profile.id,
|
||||
avatarUrl: profile.photos[0]?.value,
|
||||
username: profile.displayName,
|
||||
name: profile.name?.givenName + ' ' + profile.name?.familyName,
|
||||
emailVerified: true,
|
||||
});
|
||||
const facebookLogin = async (accessToken, refreshToken, profile, cb) => {
|
||||
try {
|
||||
const email = profile.emails[0]?.value;
|
||||
const facebookId = profile.id;
|
||||
const oldUser = await User.findOne({ email });
|
||||
const ALLOW_SOCIAL_REGISTRATION =
|
||||
process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true';
|
||||
const avatarUrl = profile.photos[0]?.value;
|
||||
|
||||
const facebookLogin = socialLogin('facebook', getProfileDetails);
|
||||
if (oldUser) {
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
return cb(null, oldUser);
|
||||
}
|
||||
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await createNewUser({
|
||||
email,
|
||||
avatarUrl,
|
||||
provider: 'facebook',
|
||||
providerKey: 'facebookId',
|
||||
providerId: facebookId,
|
||||
username: profile.displayName,
|
||||
name: profile.name?.givenName + ' ' + profile.name?.familyName,
|
||||
});
|
||||
return cb(null, newUser);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[facebookLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = () =>
|
||||
new FacebookStrategy(
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
const { Strategy: GitHubStrategy } = require('passport-github2');
|
||||
const socialLogin = require('./socialLogin');
|
||||
const { createNewUser, handleExistingUser } = require('./process');
|
||||
const { logger } = require('~/config');
|
||||
const User = require('~/models/User');
|
||||
|
||||
const getProfileDetails = (profile) => ({
|
||||
email: profile.emails[0].value,
|
||||
id: profile.id,
|
||||
avatarUrl: profile.photos[0].value,
|
||||
username: profile.username,
|
||||
name: profile.displayName,
|
||||
emailVerified: profile.emails[0].verified,
|
||||
});
|
||||
const githubLogin = async (accessToken, refreshToken, profile, cb) => {
|
||||
try {
|
||||
const email = profile.emails[0].value;
|
||||
const githubId = profile.id;
|
||||
const oldUser = await User.findOne({ email });
|
||||
const ALLOW_SOCIAL_REGISTRATION =
|
||||
process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true';
|
||||
const avatarUrl = profile.photos[0].value;
|
||||
|
||||
const githubLogin = socialLogin('github', getProfileDetails);
|
||||
if (oldUser) {
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
return cb(null, oldUser);
|
||||
}
|
||||
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await createNewUser({
|
||||
email,
|
||||
avatarUrl,
|
||||
provider: 'github',
|
||||
providerKey: 'githubId',
|
||||
providerId: githubId,
|
||||
username: profile.username,
|
||||
name: profile.displayName,
|
||||
emailVerified: profile.emails[0].verified,
|
||||
});
|
||||
return cb(null, newUser);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[githubLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = () =>
|
||||
new GitHubStrategy(
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
|
||||
const socialLogin = require('./socialLogin');
|
||||
const { createNewUser, handleExistingUser } = require('./process');
|
||||
const { logger } = require('~/config');
|
||||
const User = require('~/models/User');
|
||||
|
||||
const getProfileDetails = (profile) => ({
|
||||
email: profile.emails[0].value,
|
||||
id: profile.id,
|
||||
avatarUrl: profile.photos[0].value,
|
||||
username: profile.name.givenName,
|
||||
name: `${profile.name.givenName} ${profile.name.familyName}`,
|
||||
emailVerified: profile.emails[0].verified,
|
||||
});
|
||||
const googleLogin = async (accessToken, refreshToken, profile, cb) => {
|
||||
try {
|
||||
const email = profile.emails[0].value;
|
||||
const googleId = profile.id;
|
||||
const oldUser = await User.findOne({ email });
|
||||
const ALLOW_SOCIAL_REGISTRATION =
|
||||
process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true';
|
||||
const avatarUrl = profile.photos[0].value;
|
||||
|
||||
const googleLogin = socialLogin('google', getProfileDetails);
|
||||
if (oldUser) {
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
return cb(null, oldUser);
|
||||
}
|
||||
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await createNewUser({
|
||||
email,
|
||||
avatarUrl,
|
||||
provider: 'google',
|
||||
providerKey: 'googleId',
|
||||
providerId: googleId,
|
||||
username: profile.name.givenName,
|
||||
name: `${profile.name.givenName} ${profile.name.familyName}`,
|
||||
emailVerified: profile.emails[0].verified,
|
||||
});
|
||||
return cb(null, newUser);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[googleLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = () =>
|
||||
new GoogleStrategy(
|
||||
|
||||
43
api/strategies/joseStrategy.js
Normal file
43
api/strategies/joseStrategy.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
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;
|
||||
*/
|
||||
@@ -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,9 +11,8 @@ const jwtLogin = async () =>
|
||||
},
|
||||
async (payload, done) => {
|
||||
try {
|
||||
const user = await getUserById(payload?.id, '-password -__v');
|
||||
const user = await User.findById(payload?.id);
|
||||
if (user) {
|
||||
user.id = user._id.toString();
|
||||
done(null, user);
|
||||
} else {
|
||||
logger.warn('[jwtLogin] JwtStrategy => no user found: ' + payload?.id);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const LdapStrategy = require('passport-ldapauth');
|
||||
const { findUser, createUser, updateUser } = require('~/models/userMethods');
|
||||
const User = require('~/models/User');
|
||||
const fs = require('fs');
|
||||
|
||||
const ldapOptions = {
|
||||
@@ -36,21 +36,19 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
|
||||
userinfo.mail;
|
||||
|
||||
const username = userinfo.givenName || userinfo.mail;
|
||||
let user = await findUser({ email: userinfo.mail });
|
||||
let user = await User.findOne({ email: userinfo.mail });
|
||||
if (user && user.provider !== 'ldap') {
|
||||
return done(null, false, { message: 'Invalid credentials' });
|
||||
}
|
||||
if (!user) {
|
||||
user = {
|
||||
user = new User({
|
||||
provider: 'ldap',
|
||||
ldapId: userinfo.uid,
|
||||
username,
|
||||
email: userinfo.mail || '',
|
||||
emailVerified: true,
|
||||
name: fullName,
|
||||
};
|
||||
const userId = await createUser(user);
|
||||
user._id = userId;
|
||||
});
|
||||
} else {
|
||||
user.provider = 'ldap';
|
||||
user.ldapId = userinfo.uid;
|
||||
@@ -58,7 +56,7 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
|
||||
user.name = fullName;
|
||||
}
|
||||
|
||||
user = await updateUser(user._id, user);
|
||||
await user.save();
|
||||
|
||||
done(null, user);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
const { errorsToString } = require('librechat-data-provider');
|
||||
const { Strategy: PassportLocalStrategy } = require('passport-local');
|
||||
const { findUser, comparePassword, updateUser } = require('~/models');
|
||||
const { isEnabled, checkEmailConfig } = require('~/server/utils');
|
||||
const { loginSchema } = require('./validators');
|
||||
const logger = require('~/utils/logger');
|
||||
|
||||
// Unix timestamp for 2024-06-07 15:20:18 Eastern Time
|
||||
const verificationEnabledTimestamp = 1717788018;
|
||||
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);
|
||||
@@ -22,7 +33,7 @@ async function passportLogin(req, email, password, done) {
|
||||
return done(null, false, { message: validationError });
|
||||
}
|
||||
|
||||
const user = await findUser({ email: email.trim() });
|
||||
const user = await findUserByEmail(email);
|
||||
if (!user) {
|
||||
logError('Passport Local Strategy - User Not Found', { email });
|
||||
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
|
||||
@@ -36,24 +47,6 @@ async function passportLogin(req, email, password, done) {
|
||||
return done(null, false, { message: 'Incorrect password.' });
|
||||
}
|
||||
|
||||
const emailEnabled = checkEmailConfig();
|
||||
const userCreatedAtTimestamp = Math.floor(new Date(user.createdAt).getTime() / 1000);
|
||||
|
||||
if (
|
||||
!emailEnabled &&
|
||||
!user.emailVerified &&
|
||||
userCreatedAtTimestamp < verificationEnabledTimestamp
|
||||
) {
|
||||
await updateUser(user._id, { emailVerified: true });
|
||||
user.emailVerified = true;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
const fetch = require('node-fetch');
|
||||
const passport = require('passport');
|
||||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { Issuer, Strategy: OpenIDStrategy, custom } = require('openid-client');
|
||||
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 {
|
||||
@@ -68,13 +67,6 @@ function convertToUsername(input, defaultValue = '') {
|
||||
|
||||
async function setupOpenId() {
|
||||
try {
|
||||
if (process.env.PROXY) {
|
||||
const proxyAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
custom.setHttpOptionsDefaults({
|
||||
agent: proxyAgent,
|
||||
});
|
||||
logger.info(`[openidStrategy] proxy agent added: ${process.env.PROXY}`);
|
||||
}
|
||||
const issuer = await Issuer.discover(process.env.OPENID_ISSUER);
|
||||
const client = new issuer.Client({
|
||||
client_id: process.env.OPENID_CLIENT_ID,
|
||||
@@ -96,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 findUser({ openidId: userinfo.sub });
|
||||
let user = await User.findOne({ openidId: userinfo.sub });
|
||||
logger.info(
|
||||
`[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${userinfo.sub}`,
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
user = await findUser({ email: userinfo.email });
|
||||
user = await User.findOne({ email: userinfo.email });
|
||||
logger.info(
|
||||
`[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${
|
||||
userinfo.email
|
||||
@@ -156,16 +148,14 @@ async function setupOpenId() {
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
user = {
|
||||
user = new 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;
|
||||
@@ -173,7 +163,7 @@ async function setupOpenId() {
|
||||
user.name = fullName;
|
||||
}
|
||||
|
||||
if (userinfo.picture && !user.avatar?.includes('manual=true')) {
|
||||
if (userinfo.picture) {
|
||||
/** @type {string | undefined} */
|
||||
const imageUrl = userinfo.picture;
|
||||
|
||||
@@ -187,21 +177,25 @@ 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 = '';
|
||||
}
|
||||
|
||||
user = await updateUser(user._id, user);
|
||||
await user.save();
|
||||
|
||||
logger.info(
|
||||
`[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `,
|
||||
`[openidStrategy] login success openidId: ${user.openidId} username: ${user.username} email: ${user.email}`,
|
||||
{
|
||||
user: {
|
||||
openidId: user.openidId,
|
||||
|
||||
@@ -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 {MongoUser} oldUser - The existing user object that needs to be updated.
|
||||
* @param {User} 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'))) {
|
||||
updatedAvatar = avatarUrl;
|
||||
oldUser.avatar = avatarUrl;
|
||||
await oldUser.save();
|
||||
} else if (!isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
|
||||
const userId = oldUser._id;
|
||||
const resizedBuffer = await resizeAvatar({
|
||||
@@ -30,11 +30,8 @@ const handleExistingUser = async (oldUser, avatarUrl) => {
|
||||
input: avatarUrl,
|
||||
});
|
||||
const { processAvatar } = getStrategyFunctions(fileStrategy);
|
||||
updatedAvatar = await processAvatar({ buffer: resizedBuffer, userId });
|
||||
}
|
||||
|
||||
if (updatedAvatar) {
|
||||
await updateUser(oldUser._id, { avatar: updatedAvatar });
|
||||
oldUser.avatar = await processAvatar({ buffer: resizedBuffer, userId });
|
||||
await oldUser.save();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,7 +55,7 @@ const handleExistingUser = async (oldUser, avatarUrl) => {
|
||||
*
|
||||
* @throws {Error} Throws an error if there's an issue creating or saving the new user object.
|
||||
*/
|
||||
const createSocialUser = async ({
|
||||
const createNewUser = async ({
|
||||
email,
|
||||
avatarUrl,
|
||||
provider,
|
||||
@@ -78,24 +75,27 @@ const createSocialUser = async ({
|
||||
emailVerified,
|
||||
};
|
||||
|
||||
const newUserId = await createUser(update);
|
||||
// TODO: remove direct access of User model
|
||||
const newUser = await new User(update).save();
|
||||
|
||||
const fileStrategy = process.env.CDN_PROVIDER;
|
||||
const isLocal = fileStrategy === FileSources.local;
|
||||
|
||||
if (!isLocal) {
|
||||
const userId = newUser._id;
|
||||
const resizedBuffer = await resizeAvatar({
|
||||
userId: newUserId,
|
||||
userId,
|
||||
input: avatarUrl,
|
||||
});
|
||||
const { processAvatar } = getStrategyFunctions(fileStrategy);
|
||||
const avatar = await processAvatar({ buffer: resizedBuffer, userId: newUserId });
|
||||
await updateUser(newUserId, { avatar });
|
||||
newUser.avatar = await processAvatar({ buffer: resizedBuffer, userId });
|
||||
await newUser.save();
|
||||
}
|
||||
|
||||
return await getUserById(newUserId);
|
||||
return newUser;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
handleExistingUser,
|
||||
createSocialUser,
|
||||
createNewUser,
|
||||
};
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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 findUser({ email: email.trim() });
|
||||
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
|
||||
|
||||
if (oldUser) {
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
return cb(null, oldUser);
|
||||
}
|
||||
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await createSocialUser({
|
||||
email,
|
||||
avatarUrl,
|
||||
provider,
|
||||
providerKey: `${provider}Id`,
|
||||
providerId: id,
|
||||
username,
|
||||
name,
|
||||
emailVerified,
|
||||
});
|
||||
return cb(null, newUser);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[${provider}Login]`, err);
|
||||
return cb(err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = socialLogin;
|
||||
@@ -476,30 +476,12 @@
|
||||
* @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
|
||||
|
||||
@@ -59,8 +59,6 @@ const openAIModels = {
|
||||
'gpt-3.5-turbo-1106': 16375, // -10 from max
|
||||
'gpt-3.5-turbo-0125': 16375, // -10 from max
|
||||
'mistral-': 31990, // -10 from max
|
||||
llama3: 8187, // -5 from max
|
||||
'llama-3': 8187, // -5 from max
|
||||
};
|
||||
|
||||
const cohereModels = {
|
||||
|
||||
@@ -20,18 +20,6 @@ describe('getModelMaxTokens', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should return correct tokens for LLama 3 models', () => {
|
||||
expect(getModelMaxTokens('meta-llama/llama-3-8b')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['llama-3'],
|
||||
);
|
||||
expect(getModelMaxTokens('meta-llama/llama-3-8b')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['llama3'],
|
||||
);
|
||||
expect(getModelMaxTokens('llama-3-500b')).toBe(maxTokensMap[EModelEndpoint.openAI]['llama-3']);
|
||||
expect(getModelMaxTokens('llama3-70b')).toBe(maxTokensMap[EModelEndpoint.openAI]['llama3']);
|
||||
expect(getModelMaxTokens('llama3:latest')).toBe(maxTokensMap[EModelEndpoint.openAI]['llama3']);
|
||||
});
|
||||
|
||||
test('should return undefined for no match', () => {
|
||||
expect(getModelMaxTokens('unknown-model')).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "0.7.3",
|
||||
"version": "0.7.2",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -74,7 +74,6 @@
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-flip-toolkit": "^7.1.0",
|
||||
"react-gtm-module": "^2.0.11",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-lazy-load-image-component": "^1.6.0",
|
||||
"react-markdown": "^8.0.6",
|
||||
|
||||
@@ -196,7 +196,6 @@ export type TEditPresetProps = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type TSetOptions = (options: Record<string, unknown>) => void;
|
||||
export type TSetOptionsPayload = {
|
||||
setOption: TSetOption;
|
||||
setExample: TSetExample;
|
||||
@@ -206,7 +205,6 @@ export type TSetOptionsPayload = {
|
||||
// getConversation: () => TConversation | TPreset | null;
|
||||
checkPluginSelection: (value: string) => boolean;
|
||||
setTools: (newValue: string, remove?: boolean) => void;
|
||||
setOptions?: TSetOptions;
|
||||
};
|
||||
|
||||
export type TPresetItemProps = {
|
||||
@@ -324,7 +322,6 @@ export type TAuthContext = {
|
||||
error: string | undefined;
|
||||
login: (data: TLoginUser) => void;
|
||||
logout: () => void;
|
||||
setError: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
};
|
||||
|
||||
export type TUserContext = {
|
||||
|
||||
@@ -8,20 +8,13 @@ import LoginForm from './LoginForm';
|
||||
|
||||
function Login() {
|
||||
const localize = useLocalize();
|
||||
const { error, setError, login } = useAuthContext();
|
||||
const { error, login } = useAuthContext();
|
||||
const { startupConfig } = useOutletContext<TLoginLayoutContext>();
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <ErrorMessage>{localize(getLoginError(error))}</ErrorMessage>}
|
||||
{startupConfig?.emailLoginEnabled && (
|
||||
<LoginForm
|
||||
onSubmit={login}
|
||||
startupConfig={startupConfig}
|
||||
error={error}
|
||||
setError={setError}
|
||||
/>
|
||||
)}
|
||||
{startupConfig?.emailLoginEnabled && <LoginForm onSubmit={login} />}
|
||||
{startupConfig?.registrationEnabled && (
|
||||
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">
|
||||
{' '}
|
||||
|
||||
@@ -1,43 +1,19 @@
|
||||
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';
|
||||
import { TLoginUser } from 'librechat-data-provider';
|
||||
|
||||
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, error, setError }) => {
|
||||
const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit }) => {
|
||||
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;
|
||||
}
|
||||
|
||||
const renderError = (fieldName: string) => {
|
||||
const errorMessage = errors[fieldName]?.message;
|
||||
@@ -48,102 +24,77 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
||||
) : null;
|
||||
};
|
||||
|
||||
const handleResendEmail = () => {
|
||||
const email = getValues('email');
|
||||
if (!email) {
|
||||
return setShowResendLink(false);
|
||||
}
|
||||
resendLinkMutation.mutate({ email });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{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}
|
||||
<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_resend_link')}
|
||||
</button>
|
||||
{localize('com_auth_email_address')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<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"
|
||||
{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_continue')}
|
||||
</button>
|
||||
{localize('com_auth_password')}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
{renderError('password')}
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,37 +13,28 @@ const Registration: React.FC = () => {
|
||||
const { startupConfig, startupConfigError, isFetching } = useOutletContext<TLoginLayoutContext>();
|
||||
|
||||
const {
|
||||
watch,
|
||||
register,
|
||||
watch,
|
||||
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 [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) => {
|
||||
const onRegisterUserFormSubmit = async (data: TRegisterUser) => {
|
||||
try {
|
||||
await registerUser.mutateAsync(data);
|
||||
navigate('/c/new');
|
||||
} catch (error) {
|
||||
setError(true);
|
||||
if ((error as TError).response?.data?.message) {
|
||||
setErrorMessage((error as TError).response?.data?.message ?? '');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (startupConfig?.registrationEnabled === false) {
|
||||
@@ -85,32 +76,19 @@ const Registration: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{errorMessage && (
|
||||
{error && (
|
||||
<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((data: TRegisterUser) => registerUser.mutate(data))}
|
||||
onSubmit={handleSubmit(onRegisterUserFormSubmit)}
|
||||
>
|
||||
{renderInput('name', 'com_auth_full_name', 'text', {
|
||||
required: localize('com_auth_name_required'),
|
||||
|
||||
@@ -1,37 +1,11 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useState, ReactNode } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider';
|
||||
import type { FC } from 'react';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const BodyTextWrapper: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<div
|
||||
className="relative mt-4 rounded border border-green-400 bg-green-100 px-4 py-3 text-green-700 dark:bg-green-900 dark:text-white"
|
||||
role="alert"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResetPasswordBodyText = () => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{localize('com_auth_reset_password_if_email_exists')}
|
||||
<span>
|
||||
<a className="text-sm text-green-500 hover:underline" href="/login">
|
||||
{localize('com_auth_back_to_login')}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function RequestPasswordReset() {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
@@ -39,39 +13,72 @@ function RequestPasswordReset() {
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<TRequestPasswordReset>();
|
||||
const [bodyText, setBodyText] = useState<ReactNode | undefined>(undefined);
|
||||
const { startupConfig, setHeaderText } = useOutletContext<TLoginLayoutContext>();
|
||||
const [resetLink, setResetLink] = useState<string | undefined>(undefined);
|
||||
const [bodyText, setBodyText] = useState<React.ReactNode | undefined>(undefined);
|
||||
const { startupConfig, setError, setHeaderText } = useOutletContext<TLoginLayoutContext>();
|
||||
|
||||
const requestPasswordReset = useRequestPasswordResetMutation();
|
||||
|
||||
const onSubmit = (data: TRequestPasswordReset) => {
|
||||
requestPasswordReset.mutate(data, {
|
||||
onSuccess: (data: TRequestPasswordResetResponse) => {
|
||||
if (data.link && !startupConfig?.emailEnabled) {
|
||||
setHeaderText('com_auth_reset_password');
|
||||
setBodyText(
|
||||
<span>
|
||||
{localize('com_auth_click')}{' '}
|
||||
<a className="text-green-500 hover:underline" href={data.link}>
|
||||
{localize('com_auth_here')}
|
||||
</a>{' '}
|
||||
{localize('com_auth_to_reset_your_password')}
|
||||
</span>,
|
||||
);
|
||||
} else {
|
||||
setHeaderText('com_auth_reset_password_link_sent');
|
||||
setBodyText(<ResetPasswordBodyText />);
|
||||
if (!startupConfig?.emailEnabled) {
|
||||
setResetLink(data.link);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setHeaderText('com_auth_reset_password_link_sent');
|
||||
setBodyText(<ResetPasswordBodyText />);
|
||||
setError('com_auth_error_reset_password');
|
||||
setTimeout(() => {
|
||||
setError(null);
|
||||
}, 5000);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyText) {
|
||||
return;
|
||||
}
|
||||
if (!requestPasswordReset.isSuccess) {
|
||||
setHeaderText('com_auth_reset_password');
|
||||
setBodyText(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (startupConfig?.emailEnabled) {
|
||||
setHeaderText('com_auth_reset_password_link_sent');
|
||||
setBodyText(localize('com_auth_reset_password_email_sent'));
|
||||
return;
|
||||
}
|
||||
|
||||
setHeaderText('com_auth_reset_password');
|
||||
setBodyText(
|
||||
<span>
|
||||
{localize('com_auth_click')}{' '}
|
||||
<a className="text-green-500 hover:underline" href={resetLink}>
|
||||
{localize('com_auth_here')}
|
||||
</a>{' '}
|
||||
{localize('com_auth_to_reset_your_password')}
|
||||
</span>,
|
||||
);
|
||||
}, [
|
||||
requestPasswordReset.isSuccess,
|
||||
startupConfig?.emailEnabled,
|
||||
resetLink,
|
||||
localize,
|
||||
setHeaderText,
|
||||
bodyText,
|
||||
]);
|
||||
|
||||
if (bodyText) {
|
||||
return <BodyTextWrapper>{bodyText}</BodyTextWrapper>;
|
||||
return (
|
||||
<div
|
||||
className="relative mt-4 rounded border border-green-400 bg-green-100 px-4 py-3 text-green-700 dark:bg-green-900 dark:text-white"
|
||||
role="alert"
|
||||
>
|
||||
{bodyText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,9 +15,7 @@ const SocialButton = ({ id, enabled, serverDomain, oauthPath, Icon, label }) =>
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsHovered(false);
|
||||
if (isPressed) {
|
||||
setIsPressed(false);
|
||||
}
|
||||
if (isPressed) {setIsPressed(false);}
|
||||
};
|
||||
|
||||
const handleMouseDown = () => {
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
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;
|
||||
@@ -51,7 +51,7 @@ const setup = ({
|
||||
user: {},
|
||||
},
|
||||
},
|
||||
useGetStartupConfigReturnValue = mockStartupConfig,
|
||||
useGetStartupCongfigReturnValue = mockStartupConfig,
|
||||
} = {}) => {
|
||||
const mockUseLoginUser = jest
|
||||
.spyOn(mockDataProvider, 'useLoginUserMutation')
|
||||
@@ -64,18 +64,18 @@ const setup = ({
|
||||
const mockUseGetStartupConfig = jest
|
||||
.spyOn(mockDataProvider, 'useGetStartupConfig')
|
||||
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
|
||||
.mockReturnValue(useGetStartupConfigReturnValue);
|
||||
.mockReturnValue(useGetStartupCongfigReturnValue);
|
||||
const mockUseRefreshTokenMutation = jest
|
||||
.spyOn(mockDataProvider, 'useRefreshTokenMutation')
|
||||
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
|
||||
.mockReturnValue(useRefreshTokenMutationReturnValue);
|
||||
const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({
|
||||
startupConfig: useGetStartupConfigReturnValue.data,
|
||||
startupConfig: useGetStartupCongfigReturnValue.data,
|
||||
});
|
||||
const renderResult = render(
|
||||
<AuthLayout
|
||||
startupConfig={useGetStartupConfigReturnValue.data as TStartupConfig}
|
||||
isFetching={useGetStartupConfigReturnValue.isFetching}
|
||||
startupConfig={useGetStartupCongfigReturnValue.data as TStartupConfig}
|
||||
isFetching={useGetStartupCongfigReturnValue.isFetching}
|
||||
error={null}
|
||||
startupConfigError={null}
|
||||
header={'Welcome back'}
|
||||
@@ -161,7 +161,7 @@ test('Navigates to / on successful login', async () => {
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
},
|
||||
useGetStartupConfigReturnValue: {
|
||||
useGetStartupCongfigReturnValue: {
|
||||
...mockStartupConfig,
|
||||
data: {
|
||||
...mockStartupConfig.data,
|
||||
|
||||
@@ -1,103 +1,17 @@
|
||||
import { render } from 'test/layout-test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as mockDataProvider from 'librechat-data-provider/react-query';
|
||||
import type { TStartupConfig } from 'librechat-data-provider';
|
||||
import Login from '../LoginForm';
|
||||
|
||||
jest.mock('librechat-data-provider/react-query');
|
||||
|
||||
const mockLogin = jest.fn();
|
||||
|
||||
const mockStartupConfig: TStartupConfig = {
|
||||
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord'],
|
||||
discordLoginEnabled: true,
|
||||
facebookLoginEnabled: true,
|
||||
githubLoginEnabled: true,
|
||||
googleLoginEnabled: true,
|
||||
openidLoginEnabled: true,
|
||||
openidLabel: 'Test OpenID',
|
||||
openidImageUrl: 'http://test-server.com',
|
||||
registrationEnabled: true,
|
||||
emailLoginEnabled: true,
|
||||
socialLoginEnabled: true,
|
||||
passwordResetEnabled: true,
|
||||
serverDomain: 'mock-server',
|
||||
appTitle: '',
|
||||
ldapLoginEnabled: false,
|
||||
emailEnabled: false,
|
||||
checkBalance: false,
|
||||
showBirthdayIcon: false,
|
||||
helpAndFaqURL: '',
|
||||
};
|
||||
|
||||
const setup = ({
|
||||
useGetUserQueryReturnValue = {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {},
|
||||
},
|
||||
useLoginUserReturnValue = {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
mutate: jest.fn(),
|
||||
data: {},
|
||||
isSuccess: false,
|
||||
},
|
||||
useRefreshTokenMutationReturnValue = {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
mutate: jest.fn(),
|
||||
data: {
|
||||
token: 'mock-token',
|
||||
user: {},
|
||||
},
|
||||
},
|
||||
useGetStartupConfigReturnValue = {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: mockStartupConfig,
|
||||
},
|
||||
} = {}) => {
|
||||
const mockUseLoginUser = jest
|
||||
.spyOn(mockDataProvider, 'useLoginUserMutation')
|
||||
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
|
||||
.mockReturnValue(useLoginUserReturnValue);
|
||||
const mockUseGetUserQuery = jest
|
||||
.spyOn(mockDataProvider, 'useGetUserQuery')
|
||||
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
|
||||
.mockReturnValue(useGetUserQueryReturnValue);
|
||||
const mockUseGetStartupConfig = jest
|
||||
.spyOn(mockDataProvider, 'useGetStartupConfig')
|
||||
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
|
||||
.mockReturnValue(useGetStartupConfigReturnValue);
|
||||
const mockUseRefreshTokenMutation = jest
|
||||
.spyOn(mockDataProvider, 'useRefreshTokenMutation')
|
||||
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
|
||||
.mockReturnValue(useRefreshTokenMutationReturnValue);
|
||||
return {
|
||||
mockUseLoginUser,
|
||||
mockUseGetUserQuery,
|
||||
mockUseGetStartupConfig,
|
||||
mockUseRefreshTokenMutation,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setup();
|
||||
});
|
||||
|
||||
test('renders login form', () => {
|
||||
const { getByLabelText } = render(
|
||||
<Login onSubmit={mockLogin} startupConfig={mockStartupConfig} />,
|
||||
);
|
||||
const { getByLabelText } = render(<Login onSubmit={mockLogin} />);
|
||||
expect(getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(getByLabelText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('submits login form', async () => {
|
||||
const { getByLabelText, getByRole } = render(
|
||||
<Login onSubmit={mockLogin} startupConfig={mockStartupConfig} />,
|
||||
);
|
||||
const { getByLabelText, getByRole } = render(<Login onSubmit={mockLogin} />);
|
||||
const emailInput = getByLabelText(/email/i);
|
||||
const passwordInput = getByLabelText(/password/i);
|
||||
const submitButton = getByRole('button', { name: /Sign in/i });
|
||||
@@ -110,9 +24,7 @@ test('submits login form', async () => {
|
||||
});
|
||||
|
||||
test('displays validation error messages', async () => {
|
||||
const { getByLabelText, getByRole, getByText } = render(
|
||||
<Login onSubmit={mockLogin} startupConfig={mockStartupConfig} />,
|
||||
);
|
||||
const { getByLabelText, getByRole, getByText } = render(<Login onSubmit={mockLogin} />);
|
||||
const emailInput = getByLabelText(/email/i);
|
||||
const passwordInput = getByLabelText(/password/i);
|
||||
const submitButton = getByRole('button', { name: /Sign in/i });
|
||||
|
||||
@@ -50,7 +50,7 @@ const setup = ({
|
||||
user: {},
|
||||
},
|
||||
},
|
||||
useGetStartupConfigReturnValue = mockStartupConfig,
|
||||
useGetStartupCongfigReturnValue = mockStartupConfig,
|
||||
} = {}) => {
|
||||
const mockUseRegisterUserMutation = jest
|
||||
.spyOn(mockDataProvider, 'useRegisterUserMutation')
|
||||
@@ -63,18 +63,18 @@ const setup = ({
|
||||
const mockUseGetStartupConfig = jest
|
||||
.spyOn(mockDataProvider, 'useGetStartupConfig')
|
||||
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
|
||||
.mockReturnValue(useGetStartupConfigReturnValue);
|
||||
.mockReturnValue(useGetStartupCongfigReturnValue);
|
||||
const mockUseRefreshTokenMutation = jest
|
||||
.spyOn(mockDataProvider, 'useRefreshTokenMutation')
|
||||
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
|
||||
.mockReturnValue(useRefreshTokenMutationReturnValue);
|
||||
const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({
|
||||
startupConfig: useGetStartupConfigReturnValue.data,
|
||||
startupConfig: useGetStartupCongfigReturnValue.data,
|
||||
});
|
||||
const renderResult = render(
|
||||
<AuthLayout
|
||||
startupConfig={useGetStartupConfigReturnValue.data as TStartupConfig}
|
||||
isFetching={useGetStartupConfigReturnValue.isFetching}
|
||||
startupConfig={useGetStartupCongfigReturnValue.data as TStartupConfig}
|
||||
isFetching={useGetStartupCongfigReturnValue.isFetching}
|
||||
error={null}
|
||||
startupConfigError={null}
|
||||
header={'Create your account'}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import DropDownMenu from '~/components/Conversations/DropDownMenu';
|
||||
import ShareButton from '~/components/Conversations/ShareButton';
|
||||
import HoverToggle from '~/components/Conversations/HoverToggle';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import DropDownMenu from '../Conversations/DropDownMenu';
|
||||
import ShareButton from '../Conversations/ShareButton';
|
||||
import HoverToggle from '../Conversations/HoverToggle';
|
||||
import ExportButton from './ExportButton';
|
||||
import store from '~/store';
|
||||
|
||||
export default function ExportAndShareMenu({
|
||||
isSharedButtonEnabled,
|
||||
className = '',
|
||||
}: {
|
||||
isSharedButtonEnabled: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
export default function ExportAndShareMenu() {
|
||||
const location = useLocation();
|
||||
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0));
|
||||
const activeConvo = useRecoilValue(store.conversationByIndex(0));
|
||||
const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation);
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
let conversation: TConversation | null | undefined;
|
||||
if (location.state?.from?.pathname.includes('/chat')) {
|
||||
conversation = globalConvo;
|
||||
} else {
|
||||
conversation = activeConvo;
|
||||
}
|
||||
|
||||
const exportable =
|
||||
conversation &&
|
||||
@@ -27,7 +29,7 @@ export default function ExportAndShareMenu({
|
||||
conversation.conversationId !== 'search';
|
||||
|
||||
if (!exportable) {
|
||||
return null;
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const isActiveConvo = exportable;
|
||||
@@ -37,25 +39,22 @@ export default function ExportAndShareMenu({
|
||||
isActiveConvo={!!isActiveConvo}
|
||||
isPopoverActive={isPopoverActive}
|
||||
setIsPopoverActive={setIsPopoverActive}
|
||||
className={className}
|
||||
>
|
||||
<DropDownMenu
|
||||
icon={<Upload />}
|
||||
tooltip={localize('com_endpoint_export_share')}
|
||||
tooltip="Export/Share"
|
||||
className="pointer-cursor relative z-50 flex h-[40px] min-w-4 flex-none flex-col items-center justify-center rounded-md border border-gray-100 bg-white px-3 text-left hover:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-offset-0 radix-state-open:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700 sm:text-sm"
|
||||
>
|
||||
{conversation && conversation.conversationId && (
|
||||
<>
|
||||
<ExportButton conversation={conversation} setPopoverActive={setIsPopoverActive} />
|
||||
{isSharedButtonEnabled && (
|
||||
<ShareButton
|
||||
conversationId={conversation.conversationId}
|
||||
title={conversation.title ?? ''}
|
||||
appendLabel={true}
|
||||
className="mb-[3.5px]"
|
||||
setPopoverActive={setIsPopoverActive}
|
||||
/>
|
||||
)}
|
||||
<ShareButton
|
||||
conversationId={conversation.conversationId}
|
||||
title={conversation.title ?? ''}
|
||||
appendLabel={true}
|
||||
className="mb-[3.5px]"
|
||||
setPopoverActive={setIsPopoverActive}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DropDownMenu>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import TagManager from 'react-gtm-module';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
|
||||
import { useLocalize } from '~/hooks';
|
||||
@@ -34,47 +32,22 @@ export default function Footer({ className }: { className?: string }) {
|
||||
</a>
|
||||
);
|
||||
|
||||
if (config?.analyticsGtmId) {
|
||||
const tagManagerArgs = {
|
||||
gtmId: config?.analyticsGtmId,
|
||||
};
|
||||
TagManager.initialize(tagManagerArgs);
|
||||
}
|
||||
const mainContentRender = (
|
||||
<span>
|
||||
{typeof config?.customFooter === 'string' ? (
|
||||
config.customFooter
|
||||
) : (
|
||||
<>
|
||||
<a href="https://librechat.ai" target="_blank" rel="noreferrer" className="underline">
|
||||
{config?.appTitle || 'LibreChat'} {Constants.VERSION}
|
||||
</a>
|
||||
{' - '} {localize('com_ui_new_footer')}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const mainContentParts = (
|
||||
typeof config?.customFooter === 'string'
|
||||
? config.customFooter
|
||||
: '[LibreChat ' +
|
||||
Constants.VERSION +
|
||||
'](https://librechat.ai) - ' +
|
||||
localize('com_ui_pay_per_call')
|
||||
).split('|');
|
||||
|
||||
const mainContentRender = mainContentParts.map((text, index) => (
|
||||
<React.Fragment key={`main-content-part-${index}`}>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
a: (props) => {
|
||||
const { ['node']: _, href, ...otherProps } = props;
|
||||
return (
|
||||
<a
|
||||
className=" text-gray-600 underline dark:text-gray-300"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
p: ({ node, ...props }) => <span {...props} />,
|
||||
}}
|
||||
>
|
||||
{text.trim()}
|
||||
</ReactMarkdown>
|
||||
</React.Fragment>
|
||||
));
|
||||
|
||||
const footerElements = [...mainContentRender, privacyPolicyRender, termsOfServiceRender].filter(
|
||||
const footerElements = [mainContentRender, privacyPolicyRender, termsOfServiceRender].filter(
|
||||
Boolean,
|
||||
);
|
||||
|
||||
@@ -82,7 +55,7 @@ export default function Footer({ className }: { className?: string }) {
|
||||
<div
|
||||
className={
|
||||
className ||
|
||||
'relative flex items-center justify-center gap-2 px-2 py-2 text-center text-xs text-gray-600 dark:text-gray-300 md:px-[60px]'
|
||||
'relative flex items-center justify-center gap-2 px-2 py-2 text-xs text-gray-600 dark:text-gray-300 md:px-[60px]'
|
||||
}
|
||||
>
|
||||
{footerElements.map((contentRender, index) => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { ContextType } from '~/common';
|
||||
import { EndpointsMenu, ModelSpecsMenu, PresetsMenu, HeaderNewChat } from './Menus';
|
||||
import ExportAndShareMenu from './ExportAndShareMenu';
|
||||
import HeaderOptions from './Input/HeaderOptions';
|
||||
import { useMediaQuery } from '~/hooks';
|
||||
|
||||
const defaultInterface = getConfigDefaults().interface;
|
||||
|
||||
@@ -19,8 +18,6 @@ export default function Header() {
|
||||
[startupConfig],
|
||||
);
|
||||
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold dark:bg-gray-800 dark:text-white">
|
||||
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
|
||||
@@ -30,16 +27,8 @@ export default function Header() {
|
||||
{modelSpecs?.length > 0 && <ModelSpecsMenu modelSpecs={modelSpecs} />}
|
||||
{<HeaderOptions interfaceConfig={interfaceConfig} />}
|
||||
{interfaceConfig.presets && <PresetsMenu />}
|
||||
{isSmallScreen && (
|
||||
<ExportAndShareMenu
|
||||
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
|
||||
className="pl-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isSmallScreen && (
|
||||
<ExportAndShareMenu isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false} />
|
||||
)}
|
||||
<ExportAndShareMenu />
|
||||
</div>
|
||||
{/* Empty div for spacing */}
|
||||
<div />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { memo, useCallback, useRef, useMemo, useState, useEffect } from 'react';
|
||||
import { memo, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
supportsFiles,
|
||||
mergeFileConfig,
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import { useChatContext, useAssistantsMapContext } from '~/Providers';
|
||||
import { useAutoSave } from '~/hooks/Input/useAutoSave';
|
||||
import { useRequiresKey, useTextarea } from '~/hooks';
|
||||
import { TextareaAutosize } from '~/components/ui';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
@@ -57,14 +56,6 @@ const ChatForm = ({ index = 0 }) => {
|
||||
handleStopGenerating,
|
||||
} = useChatContext();
|
||||
|
||||
const { clearDraft } = useAutoSave({
|
||||
conversationId: useMemo(() => conversation?.conversationId, [conversation]),
|
||||
textAreaRef,
|
||||
setValue: methods.setValue,
|
||||
files,
|
||||
setFiles,
|
||||
});
|
||||
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
|
||||
const submitMessage = useCallback(
|
||||
@@ -74,9 +65,8 @@ const ChatForm = ({ index = 0 }) => {
|
||||
}
|
||||
ask({ text: data.text });
|
||||
methods.reset();
|
||||
clearDraft();
|
||||
},
|
||||
[ask, methods, clearDraft],
|
||||
[ask, methods],
|
||||
);
|
||||
|
||||
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function FileRow({
|
||||
return (
|
||||
<Image
|
||||
key={index}
|
||||
url={file.preview || file.filepath}
|
||||
url={file.preview}
|
||||
onDelete={handleDelete}
|
||||
progress={file.progress}
|
||||
source={file.source}
|
||||
|
||||
@@ -55,16 +55,14 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
|
||||
className="h-2/3 w-2/3"
|
||||
size={41}
|
||||
/>
|
||||
{!!startupConfig?.showBirthdayIcon && (
|
||||
<div>
|
||||
<TooltipTrigger>
|
||||
<BirthdayIcon className="absolute bottom-8 right-2.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={110} className="">
|
||||
{localize('com_ui_happy_birthday')}
|
||||
</TooltipContent>
|
||||
</div>
|
||||
)}
|
||||
<TooltipTrigger>
|
||||
{(startupConfig?.showBirthdayIcon ?? false) && (
|
||||
<BirthdayIcon className="absolute bottom-12 right-5" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={115} className="left-[20%]">
|
||||
{localize('com_ui_happy_birthday')}
|
||||
</TooltipContent>
|
||||
</div>
|
||||
{assistantName ? (
|
||||
<div className="flex flex-col items-center gap-0 p-2">
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
|
||||
import type { TModelsConfig, TEndpointsConfig } from 'librechat-data-provider';
|
||||
import {
|
||||
cn,
|
||||
defaultTextProps,
|
||||
removeFocusOutlines,
|
||||
mapEndpoints,
|
||||
getConvoSwitchLogic,
|
||||
} from '~/utils';
|
||||
import { Input, Label, Dropdown, Dialog, DialogClose, DialogButton } from '~/components';
|
||||
import { useSetIndexOptions, useLocalize, useDebouncedInput } from '~/hooks';
|
||||
import { cn, defaultTextProps, removeFocusOutlines, mapEndpoints } from '~/utils';
|
||||
import { Input, Label, Dropdown, Dialog, DialogClose, DialogButton } from '~/components/';
|
||||
import PopoverButtons from '~/components/Chat/Input/PopoverButtons';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { useSetIndexOptions, useLocalize, useDebouncedInput } from '~/hooks';
|
||||
import { EndpointSettings } from '~/components/Endpoints';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import store from '~/store';
|
||||
@@ -27,9 +17,8 @@ const EditPresetDialog = ({
|
||||
submitPreset: () => void;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const { preset, setPreset } = useChatContext();
|
||||
const { setOption, setOptions, setAgentOption } = useSetIndexOptions(preset);
|
||||
const { setOption } = useSetIndexOptions(preset);
|
||||
const [onTitleChange, title] = useDebouncedInput({
|
||||
setOption,
|
||||
optionKey: 'title',
|
||||
@@ -41,67 +30,6 @@ const EditPresetDialog = ({
|
||||
select: mapEndpoints,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
if (!preset.endpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelsConfig = queryClient.getQueryData<TModelsConfig>([QueryKeys.models]);
|
||||
if (!modelsConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const models = modelsConfig[preset.endpoint];
|
||||
if (!models) {
|
||||
return;
|
||||
}
|
||||
if (!models.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preset.model === models[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!models.includes(preset.model ?? '')) {
|
||||
console.log('setting model', models[0]);
|
||||
setOption('model')(models[0]);
|
||||
}
|
||||
|
||||
if (preset.agentOptions?.model === models[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preset.agentOptions?.model && !models.includes(preset.agentOptions.model)) {
|
||||
console.log('setting agent model', models[0]);
|
||||
setAgentOption('model')(models[0]);
|
||||
}
|
||||
}, [preset, queryClient, setOption, setAgentOption]);
|
||||
|
||||
const switchEndpoint = useCallback(
|
||||
(newEndpoint: string) => {
|
||||
if (!setOptions) {
|
||||
return console.warn('setOptions is not defined');
|
||||
}
|
||||
|
||||
const { newEndpointType } = getConvoSwitchLogic({
|
||||
newEndpoint,
|
||||
modularChat: true,
|
||||
conversation: null,
|
||||
endpointsConfig: queryClient.getQueryData<TEndpointsConfig>([QueryKeys.endpoints]) ?? {},
|
||||
});
|
||||
|
||||
setOptions({
|
||||
endpoint: newEndpoint,
|
||||
endpointType: newEndpointType,
|
||||
});
|
||||
},
|
||||
[queryClient, setOptions],
|
||||
);
|
||||
|
||||
const { endpoint, endpointType, model } = preset || {};
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
@@ -148,7 +76,7 @@ const EditPresetDialog = ({
|
||||
</Label>
|
||||
<Dropdown
|
||||
value={endpoint || ''}
|
||||
onChange={switchEndpoint}
|
||||
onChange={(value) => setOption('endpoint')(value)}
|
||||
options={availableEndpoints}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useUpdateMessageMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TEditProps } from '~/common';
|
||||
import { cn, removeFocusRings } from '~/utils';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Container from './Container';
|
||||
@@ -111,42 +111,36 @@ const EditMessage = ({
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border dark:border-gray-600 dark:text-white [&:has(textarea:focus)]:border-gray-300 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)] dark:[&:has(textarea:focus)]:border-gray-500">
|
||||
<TextareaAutosize
|
||||
ref={textAreaRef}
|
||||
onChange={(e) => {
|
||||
setEditedText(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="message-text-editor"
|
||||
className={cn(
|
||||
'markdown prose dark:prose-invert light whitespace-pre-wrap break-words',
|
||||
'pl-3 md:pl-4',
|
||||
'm-0 w-full resize-none border-0 bg-transparent py-[10px]',
|
||||
'placeholder-black/50 focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:placeholder-white/50 md:py-3.5 ',
|
||||
'pr-3 md:pr-4',
|
||||
'max-h-[65vh] md:max-h-[75vh]',
|
||||
removeFocusRings,
|
||||
)}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
<TextareaAutosize
|
||||
ref={textAreaRef}
|
||||
onChange={(e) => {
|
||||
setEditedText(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="message-text-editor"
|
||||
className={cn(
|
||||
'markdown prose dark:prose-invert light whitespace-pre-wrap break-words dark:text-gray-20',
|
||||
'm-0 w-full resize-none border-0 bg-transparent p-0',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const pastedData = e.clipboardData.getData('text/plain');
|
||||
const textArea = textAreaRef.current;
|
||||
if (!textArea) {
|
||||
return;
|
||||
}
|
||||
const start = textArea.selectionStart;
|
||||
const end = textArea.selectionEnd;
|
||||
const newValue =
|
||||
textArea.value.substring(0, start) + pastedData + textArea.value.substring(end);
|
||||
setEditedText(newValue);
|
||||
}}
|
||||
contentEditable={true}
|
||||
value={editedText}
|
||||
suppressContentEditableWarning={true}
|
||||
/>
|
||||
</div>
|
||||
const pastedData = e.clipboardData.getData('text/plain');
|
||||
const textArea = textAreaRef.current;
|
||||
if (!textArea) {
|
||||
return;
|
||||
}
|
||||
const start = textArea.selectionStart;
|
||||
const end = textArea.selectionEnd;
|
||||
const newValue =
|
||||
textArea.value.substring(0, start) + pastedData + textArea.value.substring(end);
|
||||
setEditedText(newValue);
|
||||
}}
|
||||
contentEditable={true}
|
||||
value={editedText}
|
||||
suppressContentEditableWarning={true}
|
||||
/>
|
||||
<div className="mt-2 flex w-full justify-center text-center">
|
||||
<button
|
||||
className="btn btn-primary relative mr-2"
|
||||
|
||||
@@ -21,8 +21,7 @@ const Conversations = ({
|
||||
);
|
||||
const firstTodayConvoId = useMemo(
|
||||
() =>
|
||||
conversations.find((convo) => convo && convo.updatedAt && isToday(parseISO(convo.updatedAt)))
|
||||
?.conversationId,
|
||||
conversations.find((convo) => convo && isToday(parseISO(convo.updatedAt)))?.conversationId,
|
||||
[conversations],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { useGetEndpointsQuery, useGetStartupConfig } from 'librechat-data-provider/react-query';
|
||||
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
|
||||
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
|
||||
import { useUpdateConversationMutation } from '~/data-provider';
|
||||
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
|
||||
@@ -27,9 +27,9 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
|
||||
const activeConvos = useRecoilValue(store.allConversationsSelector);
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { navigateWithLastTools } = useNavigateToConvo();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { refreshConversations } = useConversations();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const { conversationId, title } = conversation;
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [titleInput, setTitleInput] = useState(title);
|
||||
@@ -126,15 +126,13 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
|
||||
setIsPopoverActive={setIsPopoverActive}
|
||||
>
|
||||
<DropDownMenu>
|
||||
{startupConfig && startupConfig.sharedLinksEnabled && (
|
||||
<ShareButton
|
||||
conversationId={conversationId}
|
||||
title={title}
|
||||
appendLabel={true}
|
||||
className="mb-[3.5px]"
|
||||
setPopoverActive={setIsPopoverActive}
|
||||
/>
|
||||
)}
|
||||
<ShareButton
|
||||
conversationId={conversationId}
|
||||
title={title}
|
||||
appendLabel={true}
|
||||
className="mb-[3.5px]"
|
||||
setPopoverActive={setIsPopoverActive}
|
||||
/>
|
||||
|
||||
<RenameButton
|
||||
renaming={renaming}
|
||||
|
||||
@@ -7,26 +7,23 @@ const HoverToggle = ({
|
||||
isActiveConvo,
|
||||
isPopoverActive,
|
||||
setIsPopoverActive,
|
||||
className = 'absolute bottom-0 right-0 top-0',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isActiveConvo: boolean;
|
||||
isPopoverActive: boolean;
|
||||
setIsPopoverActive: (isActive: boolean) => void;
|
||||
className?: string;
|
||||
}) => {
|
||||
const setPopoverActive = (value: boolean) => setIsPopoverActive(value);
|
||||
return (
|
||||
<ToggleContext.Provider value={{ isPopoverActive, setPopoverActive }}>
|
||||
<div
|
||||
className={cn(
|
||||
'peer items-center gap-1.5 rounded-r-lg from-gray-500 from-gray-900 pl-2 pr-2 dark:text-white',
|
||||
'peer absolute bottom-0 right-0 top-0 items-center gap-1.5 rounded-r-lg from-gray-500 from-gray-900 pl-2 pr-2 dark:text-white',
|
||||
isPopoverActive || isActiveConvo ? 'flex' : 'hidden group-hover:flex',
|
||||
isActiveConvo
|
||||
? 'from-gray-50 from-85% to-transparent group-hover:bg-gradient-to-l group-hover:from-gray-200 dark:from-gray-800 dark:group-hover:from-gray-800'
|
||||
: 'z-50 from-gray-200 from-gray-50 from-0% to-transparent hover:bg-gradient-to-l hover:from-gray-200 dark:from-gray-750 dark:from-gray-800 dark:hover:from-gray-800',
|
||||
isPopoverActive && !isActiveConvo ? 'from-gray-50 dark:from-gray-800' : '',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user