Compare commits

...

32 Commits

Author SHA1 Message Date
Danny Avila
302b28fc9b v0.7.4-rc1 (#3099)
* fix(openIdStrategy): return user object on new user creation

*  v0.7.4-rc1
2024-06-17 12:47:28 -04:00
Marco Beretta
dad25bd297 📝 docs: update README (#3093) 2024-06-17 07:51:13 -04:00
Marco Beretta
a338decf90 ✉️ fix: email address encoding in verification link (#3085)
Related to #3084

Implements URL encoding for email addresses in verification links and decodes them upon verification.

- **Encode email addresses** in `sendVerificationEmail` and `resendVerificationEmail` functions using `encodeURIComponent` to ensure special characters like `+` are correctly handled in the verification link.
- **Decode email addresses** in the `verifyEmail` function using `decodeURIComponent` to accurately retrieve and validate the email address from the verification link against the database.


---

For more details, open the [Copilot Workspace session](https://copilot-workspace.githubnext.com/danny-avila/LibreChat/issues/3084?shareId=9c32df30-4156-4082-a3eb-fff54eaba5b3).
2024-06-16 16:05:53 -04:00
Danny Avila
2cf5228021 🕑 fix: Add Suspense to Connection Error Messages (#3074) 2024-06-15 16:16:06 -04:00
Danny Avila
0294cfc881 v0.7.3 (#3067)
* refactor: revert BaseClient to use node-fetch instead of undici

* chore: bump version

* chore: update npm dependencies to latest versions

* chore: fix custom footer
2024-06-15 12:17:10 -04:00
Yuichi Oneda
8d8b17e7ed 🚀 feat: Add Option to Disable Shared Links (#2986)
* feat: add option to disable shared links

* chore: update languages

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2024-06-15 11:12:03 -04:00
Danny Avila
04502e9525 👤 fix: Create User with timestamps (#3070)
* 👤 fix: Create User with timestamps

* chore: fix lint script to ignore venv

* chore: linting
2024-06-15 10:36:49 -04:00
btribonde
bcaa7d5d29 🛤️ feat: Proxy Support for OpenID Login (#3051)
https://github.com/danny-avila/LibreChat/issues/3041
2024-06-15 09:41:34 -04:00
Marco Beretta
c288b458b6 📝 docs: update README's features (#3011)
* Update README.md

* Update README.md

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2024-06-15 09:36:05 -04:00
Neelesh Kumar
447bbcb8ca 📝 chore: Update .env.example with Custom Endpoint API Key examples (#2970)
Add env variables for cohere and databricks
2024-06-15 09:31:39 -04:00
Marco Beretta
68bf7ac7c0 🪲 fix(useTextarea): enterToSend bugs (#2988)
Co-authored-by: Danny Avila <danny@librechat.ai>
2024-06-15 09:30:19 -04:00
Denis Palnitsky
97d12d03d1 📊 feat: Google tag manager integration (#2469)
* Google tag manager integration

* change location of react-gtm-module package

* refactor: move react-gtm-module usage from Chat/Footer to useAppStartup hook

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2024-06-15 08:09:18 -04:00
Danny Avila
4416f69a9b 🚀 refactor: Use Undici Instead of Node-Fetch, prevent Event Close, add Index (#3052)
* feat: Add index to conversationId field in messageSchema

* refactor: prevent immediate event close on error

* refactor: use undici instead of node-fetch in non-Bun environment
2024-06-13 13:38:15 -04:00
Yuichi Oneda
29e71e98ad ✍️ feat: Automatic Save and Restore for Chat (#2942)
* feat: added "Save draft locally" to Message settings

* feat: add hook to save chat input as draft every second

* fix: use filepath if the file does not have a preview prop

* fix: not to delete temporary files when navigating to a new chat

* chore: translations

* chore: import order

* chore: import order

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2024-06-13 09:52:30 -04:00
Jakub Mieszczak
e9bbf39618 📝 feat: Markdown support for Custom Footer links (#2960)
* feat: Add markdown support for custom footer links

* fix: Update Footer component with revised ReactMarkdown props

* fix: Adjusted footer links and pipe styles for consistent appearance

* refactor: remove unused footer.tsx file
2024-06-13 09:51:28 -04:00
Danny Avila
08b8ae120e 🤖 fix(Assistants): Ensure Required Actions always have Outputs (#3031) 2024-06-10 22:01:52 -04:00
Danny Avila
803fd63121 📋 fix(handlePaste): Remove Custom rich-text handling (#3025)
* fix: rich text edge case

* fix(useTextarea): removing custom handling fixes RTF slow loading, default behavior with form prefers plain-text
2024-06-10 14:36:51 -04:00
Yuichi Oneda
ef76cc195e 🎨 style(Textarea): Message Edit Textarea Styling (#3009) 2024-06-10 13:01:07 -04:00
Danny Avila
2e559137ae ℹ️ refactor: Remove use of Agenda for Conversation Imports (#3024)
* chore: remove agenda and npm audit fix

* refactor: import conversations without agenda

* chore: update package-lock.json and data-provider version to 0.6.7

* fix: import conversations

* chore: client npm audit fix
2024-06-10 13:00:34 -04:00
Danny Avila
92232afaca 📧 fix: Cancel Signup if Email Issuance Fails (#3010)
* fix: user.id assignment in jwtStrategy.js

* refactor(sendEmail): pass params as object, await email sending to propogate errors and restrict registration flow

* fix(Conversations): handle missing updatedAt field

* refactor: use `processDeleteRequest` when deleting user account for user file deletion

* refactor: delete orphaned files when deleting user account

* fix: remove unnecessary 404 status code in server/index.js
2024-06-08 06:51:29 -04:00
Danny Avila
084cf266a2 🗃️ fix: revise RAG prompt (#3006) 2024-06-07 20:51:43 -04:00
Danny Avila
baf0848021 📧 fix: LDAP login after User verification changes (#3003) 2024-06-07 17:43:36 -04:00
Danny Avila
1da92111aa 🚀 refactor: Remove Local Login Redundancies (#3002) 2024-06-07 16:45:31 -04:00
Danny Avila
35f8053f45 📧 fix: Ensure User Verification for Instances without Email Service (#2998) 2024-06-07 15:43:43 -04:00
Marco Beretta
ee673d682e 📧 feat: email verification (#2344)
* feat: verification email

* chore: email verification invalid; localize: update

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

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

* fix: user needs to be authenticated

* style: update

* fix: registration success message and redirect logic

* refactor: use `isEnabled` in ALLOW_UNVERIFIED_EMAIL_LOGIN

* refactor: move checkEmailConfig to server/utils

* refactor: use req as param for verifyEmail function

* chore: jsdoc

* chore: remove console log

* refactor: rename `createNewUser` to `createSocialUser`

* refactor: update typing and add expiresAt field to userSchema

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

* refactor: initial email verification rewrite

* chore: typing

* refactor: registration flow rewrite

* chore: remove help center text

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

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

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

* refactor: logout on deleteUser mutatation

* refactor: Update openidStrategy login success message format

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

* refactor: move limiters to separate middleware dir

* refactor: Add limiters for email verification and password reset

* refactor: Remove getUserController and update routes and controllers accordingly

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

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

* refactor: Improve email verification process and resend option

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

* refactor: replace user authentication methods and token generation

* fix: add user.id to jwt user

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

* fix(updateUserPluginsService): ensure userPlugins variable is defined

* refactor: Delete all shared links for a specific user

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

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

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2024-06-07 15:06:47 -04:00
Marco Beretta
b7fef6958b 🔒refactor: social login and remove direct user model access in strategies (#2946)
* refactor: checking `ALLOW_SOCIAL_REGISTRATION` with `isEnabled`

* feat: Add findUserByEmail function to UserService

This commit adds a new function, , to the  module. This function retrieves a user document from the database based on the provided email. It returns the user document if found, otherwise it returns null. If there is a problem during user retrieval, an error is thrown.

* refactor: add socialLogin to remove repetitive code
2024-06-06 13:23:11 -04:00
Marco Beretta
5452d4c20c 🔒 feat: password reset disable option; fix: account email error message (#2327)
* feat: password reset  disable option; fix: account email leak

* fix(LoginSpec): typo

* test: fixed LoginForm test

* fix: disable password reset when undefined

* refactor: use a helper function

* fix: tests

* feat: Remove unused error message in password reset process

* chore: Update password reset email message

* refactor: only allow password reset if explicitly allowed

* feat: Add password reset email service configuration check

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

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

* chore: remove import order rules

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

* chore: make password reset warning more prominent

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

* refactor: fix styling of password reset email message

* chore: add missing type for passwordResetEnabled, TStartupConfig

* fix(LoginForm): prevent login form flickering

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

* refactor: Improve password reset UI, applies DRY

* chore: Add logging to password reset validation middleware

* chore(CONTRIBUTING): Update import order conventions

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
Co-authored-by: Berry-13 <root@Berry>
Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
2024-06-06 11:39:36 -04:00
Marco Beretta
a7f5b57272 🚫👤feat: delete user from UI (#1526)
* initial commit

* fix: UserController bugs; fix: lint errors

* fix: delete files

* language support

* style(DeleteAccount): update to the latest style

* style: fix after merge main

* chore: Add canDeleteAccount middleware for user deletion endpoint

* chore: renamed to ALLOW_ACCOUNT_DELETION

* fix(canDeleteAccount): use uppercase admin role

* chore: imports order

* chore: Enable account deletion by default if omitted/commented out

* chore: Add logging for user account deletion

* chore: Bump data-provider package version to 0.6.6

* chore: Import Transaction model in UserController

* chore: Update CONFIG_VERSION to 1.1.4

* chore: Update user account deletion logging

* chore: Refactor user account deletion logic

---------

Co-authored-by: Berry-13 <root@Berry>
Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2024-06-05 19:35:12 -04:00
Marco Beretta
f69b317171 🔧 fix(useMentions): handle empty assistant lists (#2966)
The useMentions hook in the client/src/hooks/Input/useMentions.ts file has been updated to handle cases where the assistant lists for the endpoints 'assistants' and 'azureAssistants' are empty. This change ensures that the hook does not throw an error when attempting to access assistantListMap[EModelEndpoint.assistants] or assistantListMap[EModelEndpoint.azureAssistants]. Instead, it defaults to an empty array for these cases.
2024-06-05 14:56:15 -04:00
Yuichi Oneda
4469ba72fc 🧹 chore(.eslintrc.js): Update Import Order of The React Types (#2964) 2024-06-05 14:55:42 -04:00
Yuichi Oneda
0e3e45e77d 🔧 chore: Add import/order Eslint Rule (#2928)
* chore: add import order eslint rule

* refactor: apply 'import/order' rule
2024-06-04 08:56:26 -04:00
Marco Beretta
9f0c1914a5 🎂 fix: birthday icon (#2950)
* fix: tooltip and birthday icon

* chore: update conditional render

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2024-06-04 08:55:41 -04:00
152 changed files with 3926 additions and 2374 deletions

View File

@@ -64,6 +64,8 @@ PROXY=
# ANYSCALE_API_KEY=
# APIPIE_API_KEY=
# COHERE_API_KEY=
# DATABRICKS_API_KEY=
# FIREWORKS_API_KEY=
# GROQ_API_KEY=
# HUGGINGFACE_TOKEN=
@@ -319,6 +321,9 @@ 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
@@ -394,6 +399,13 @@ FIREBASE_STORAGE_BUCKET=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_APP_ID=
#========================#
# Shared Links #
#========================#
ALLOW_SHARED_LINKS=true
ALLOW_SHARED_LINKS_PUBLIC=true
#===================================================#
# UI #
#===================================================#
@@ -404,6 +416,9 @@ 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 #
#==================================================#

View File

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

View File

@@ -1,4 +1,4 @@
# v0.7.2
# v0.7.3
# Base node image
FROM node:20-alpine AS node

View File

@@ -1,4 +1,4 @@
# v0.7.2
# v0.7.3
# Build API, Client and Data Provider
FROM node:20-alpine AS base

View File

@@ -58,9 +58,13 @@
- 🌎 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.
- 🎨 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
- 📥 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
@@ -77,7 +81,7 @@ LibreChat brings together the future of assistant AIs with the revolutionary tec
With LibreChat, you no longer need to opt for ChatGPT Plus and can instead use free or pay-per-call APIs. We welcome contributions, cloning, and forking to enhance the capabilities of this advanced chatbot platform.
[![Watch the video](https://img.youtube.com/vi/YLVUW5UP9N0/maxresdefault.jpg)](https://www.youtube.com/watch?v=YLVUW5UP9N0)
[![Watch the video](https://img.youtube.com/vi/bSVHEbVPNl4/maxresdefault.jpg)](https://www.youtube.com/watch?v=bSVHEbVPNl4)
Click on the thumbnail to open the video☝
---

View File

@@ -69,6 +69,9 @@ 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);
}

View File

@@ -8,8 +8,6 @@ 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) {
@@ -94,37 +92,40 @@ function createContextHandlers(req, userMessageContent) {
const resolvedQueries = await Promise.all(queryPromises);
const context = resolvedQueries
.map((queryResult, index) => {
const file = processedFiles[index];
let contextItems = queryResult.data;
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 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}

View File

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

View File

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

View File

@@ -97,8 +97,12 @@ 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) => {
return await File.deleteMany({ file_id: { $in: file_ids } });
const deleteFiles = async (file_ids, user) => {
let deleteQuery = { file_id: { $in: file_ids } };
if (user) {
deleteQuery = { user: user };
}
return await File.deleteMany(deleteQuery);
};
module.exports = {

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ const messageSchema = mongoose.Schema(
},
conversationId: {
type: String,
index: true,
required: true,
meiliIndex: true,
},

View File

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

View File

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

View File

@@ -1,28 +1,37 @@
const bcrypt = require('bcryptjs');
const signPayload = require('~/server/services/signPayload');
const User = require('./User');
const hashPassword = async (password) => {
const hashedPassword = await new Promise((resolve, reject) => {
bcrypt.hash(password, 10, function (err, hash) {
if (err) {
reject(err);
} else {
resolve(hash);
}
});
});
return hashedPassword;
};
/**
* Retrieve a user by ID and convert the found user document to a plain object.
*
* @param {string} userId - The ID of the user to find and return as a plain object.
* @returns {Promise<Object>} A plain object representing the user document, or `null` if no user is found.
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
* @returns {Promise<MongoUser>} A plain object representing the user document, or `null` if no user is found.
*/
const getUser = async function (userId) {
return await User.findById(userId).lean();
const getUserById = async function (userId, fieldsToSelect = null) {
const query = User.findById(userId);
if (fieldsToSelect) {
query.select(fieldsToSelect);
}
return await query.lean();
};
/**
* Search for a single user based on partial data and return matching user document as plain object.
* @param {Partial<MongoUser>} searchCriteria - The partial data to use for searching the user.
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
* @returns {Promise<MongoUser>} A plain object representing the user document, or `null` if no user is found.
*/
const findUser = async function (searchCriteria, fieldsToSelect = null) {
const query = User.findOne(searchCriteria);
if (fieldsToSelect) {
query.select(fieldsToSelect);
}
return await query.lean();
};
/**
@@ -30,17 +39,136 @@ const getUser = async function (userId) {
*
* @param {string} userId - The ID of the user to update.
* @param {Object} updateData - An object containing the properties to update.
* @returns {Promise<Object>} The updated user document as a plain object, or `null` if no user is found.
* @returns {Promise<MongoUser>} The updated user document as a plain object, or `null` if no user is found.
*/
const updateUser = async function (userId, updateData) {
return await User.findByIdAndUpdate(userId, updateData, {
const updateOperation = {
$set: updateData,
$unset: { expiresAt: '' }, // Remove the expiresAt field to prevent TTL
};
return await User.findByIdAndUpdate(userId, updateOperation, {
new: true,
runValidators: true,
}).lean();
};
module.exports = {
hashPassword,
updateUser,
getUser,
/**
* Creates a new user, optionally with a TTL of 1 week.
* @param {MongoUser} data - The user data to be created, must contain user_id.
* @param {boolean} [disableTTL=true] - Whether to disable the TTL. Defaults to `true`.
* @param {boolean} [returnUser=false] - 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, returnUser = false) => {
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);
if (returnUser) {
return user.toObject();
}
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,
updateUser,
findUser,
};

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "0.7.2",
"version": "0.7.3",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -41,7 +41,6 @@
"@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",
"cheerio": "^1.0.0-rc.12",

View File

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

View File

@@ -1,11 +1,37 @@
const { updateUserPluginsService } = require('~/server/services/UserService');
const {
Session,
Balance,
getFiles,
deleteFiles,
deleteConvos,
deletePresets,
deleteMessages,
deleteUserById,
} = require('~/models');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
const { 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;
@@ -49,11 +75,68 @@ const updateUserPluginsController = async (req, res) => {
res.status(200).send();
} catch (err) {
logger.error('[updateUserPluginsController]', err);
res.status(500).json({ message: err.message });
return res.status(500).json({ message: 'Something went wrong.' });
}
};
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.' });
}
};
module.exports = {
getUserController,
deleteUserController,
verifyEmailController,
updateUserPluginsController,
resendVerificationController,
};

View File

@@ -1,26 +1,22 @@
const User = require('~/models/User');
const { setAuthTokens } = require('~/server/services/AuthService');
const { logger } = require('~/config');
const loginController = async (req, res) => {
try {
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 ??
if (!req.user) {
return res.status(400).json({ message: 'Invalid credentials' });
}
const token = await setAuthTokens(user._id, res);
const { password: _, __v, ...user } = req.user;
user.id = user._id.toString();
const token = await setAuthTokens(req.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 = {

View File

@@ -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.status(404).sendFile(path.join(app.locals.paths.dist, 'index.html'));
res.sendFile(path.join(app.locals.paths.dist, 'index.html'));
});
app.listen(port, host, () => {

View File

@@ -0,0 +1,27 @@
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;

View File

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

View File

@@ -1,47 +1,43 @@
const abortMiddleware = require('./abortMiddleware');
const checkBan = require('./checkBan');
const checkDomainAllowed = require('./checkDomainAllowed');
const uaParser = require('./uaParser');
const setHeaders = require('./setHeaders');
const loginLimiter = require('./loginLimiter');
const validateModel = require('./validateModel');
const requireJwtAuth = require('./requireJwtAuth');
const requireLdapAuth = require('./requireLdapAuth');
const uploadLimiters = require('./uploadLimiters');
const registerLimiter = require('./registerLimiter');
const messageLimiters = require('./messageLimiters');
const requireLocalAuth = require('./requireLocalAuth');
const validateEndpoint = require('./validateEndpoint');
const concurrentLimiter = require('./concurrentLimiter');
const validateMessageReq = require('./validateMessageReq');
const buildEndpointOption = require('./buildEndpointOption');
const validatePasswordReset = require('./validatePasswordReset');
const validateRegistration = require('./validateRegistration');
const validateImageRequest = require('./validateImageRequest');
const 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,
...messageLimiters,
...limiters,
noIndex,
checkBan,
uaParser,
setHeaders,
loginLimiter,
moderateText,
validateModel,
requireJwtAuth,
requireLdapAuth,
registerLimiter,
requireLocalAuth,
canDeleteAccount,
validateEndpoint,
concurrentLimiter,
checkDomainAllowed,
validateMessageReq,
buildEndpointOption,
validateRegistration,
validateImageRequest,
validateModel,
moderateText,
noIndex,
...importLimiters,
checkDomainAllowed,
validatePasswordReset,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,15 @@ 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 = () => {
@@ -42,6 +51,7 @@ router.get('/', async function (req, res) {
!!process.env.EMAIL_USERNAME &&
!!process.env.EMAIL_PASSWORD &&
!!process.env.EMAIL_FROM,
passwordResetEnabled,
checkBalance: isEnabled(process.env.CHECK_BALANCE),
showBirthdayIcon:
isBirthday() ||
@@ -50,6 +60,9 @@ 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') {

View File

@@ -3,12 +3,11 @@ 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');
@@ -129,10 +128,9 @@ router.post(
upload.single('file'),
async (req, res) => {
try {
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 });
/* 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' });
} catch (error) {
logger.error('Error processing file', error);
res.status(500).send('Error processing file');
@@ -169,24 +167,4 @@ 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;

View File

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

View File

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

View File

@@ -8,21 +8,33 @@ 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
*/
router.get('/:shareId', async (req, res) => {
const share = await getSharedMessages(req.params.shareId);
const allowSharedLinks =
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
if (share) {
res.status(200).json(share);
} else {
res.status(404).end();
}
});
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();
}
},
);
}
/**
* Shared links

View File

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

View File

@@ -1,13 +1,21 @@
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,
@@ -15,6 +23,7 @@ const domains = {
};
const isProduction = process.env.NODE_ENV === 'production';
const genericVerificationMessage = 'Please check your email to verify your email address.';
/**
* Logout user
@@ -45,10 +54,72 @@ const logoutUser = async (userId, refreshToken) => {
};
/**
* Register a new user
*
* @param {Object} user <email, password, name, username>
* @returns
* Send Verification Email
* @param {Partial<MongoUser> & { _id: ObjectId, email: string, name: string}} user
* @returns {Promise<void>}
*/
const sendVerificationEmail = async (user) => {
let verifyToken = crypto.randomBytes(32).toString('hex');
const hash = bcrypt.hashSync(verifyToken, 10);
const verificationLink = `${domains.client}/verify?token=${verifyToken}&email=${encodeURIComponent(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: decodeURIComponent(email) });
if (!emailVerificationData) {
logger.warn(`[verifyEmail] [No email verification data found] [Email: ${email}]`);
return new Error('Invalid or expired password reset token');
}
const isValid = bcrypt.compareSync(token, emailVerificationData.token);
if (!isValid) {
logger.warn(`[verifyEmail] [Invalid or expired email verification token] [Email: ${email}]`);
return new Error('Invalid or expired email verification token');
}
const updatedUser = await updateUser(emailVerificationData.userId, { emailVerified: true });
if (!updatedUser) {
logger.warn(`[verifyEmail] [User not found] [Email: ${email}]`);
return new Error('User not found');
}
await emailVerificationData.deleteOne();
logger.info(`[verifyEmail] Email verification successful. [Email: ${email}]`);
return { message: 'Email verification was successful' };
};
/**
* Register a new user.
* @param {MongoUser} user <email, password, name, username>
* @returns {Promise<{status: number, message: string, user?: MongoUser}>}
*/
const registerUser = async (user) => {
const { error } = registerSchema.safeParse(user);
@@ -60,13 +131,14 @@ const registerUser = async (user) => {
{ name: 'Validation error:', value: errorMessage },
);
return { status: 422, message: errorMessage };
return { status: 404, message: errorMessage };
}
const { email, password, name, username } = user;
let newUserId;
try {
const existingUser = await User.findOne({ email }).lean();
const existingUser = await findUser({ email }, 'email _id');
if (existingUser) {
logger.info(
@@ -77,51 +149,71 @@ const registerUser = async (user) => {
// Sleep for 1 second
await new Promise((resolve) => setTimeout(resolve, 1000));
// TODO: We should change the process to always email and be generic is signup works or fails (user enum)
return { status: 500, message: 'Something went wrong' };
return { status: 200, message: genericVerificationMessage };
}
if (!(await isDomainAllowed(email))) {
const errorMessage = 'Registration from this domain is not allowed.';
const errorMessage =
'The email address provided cannot be used. Please use a different email address.';
logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`);
return { status: 403, message: errorMessage };
}
//determine if this is the first registered user (not counting anonymous_user)
const isFirstRegisteredUser = (await User.countDocuments({})) === 0;
const isFirstRegisteredUser = (await countUsers()) === 0;
const newUser = await new User({
const salt = bcrypt.genSaltSync(10);
const newUserData = {
provider: 'local',
email,
password,
username,
name,
avatar: null,
role: isFirstRegisteredUser ? 'ADMIN' : 'USER',
});
password: bcrypt.hashSync(password, salt),
};
const salt = bcrypt.genSaltSync(10);
const hash = bcrypt.hashSync(newUser.password, salt);
newUser.password = hash;
await newUser.save();
const emailEnabled = checkEmailConfig();
newUserId = await createUser(newUserData, false);
if (emailEnabled) {
await sendVerificationEmail({
_id: newUserId,
email,
name,
});
} else {
await updateUser(newUserId, { emailVerified: true });
}
return { status: 200, user: newUser };
return { status: 200, message: genericVerificationMessage };
} catch (err) {
return { status: 500, message: err?.message || 'Something went wrong' };
logger.error('[registerUser] Error in registering user:', err);
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' };
}
};
/**
* Request password reset
*
* @param {String} email
* @returns
* @param {Express.Request} req
*/
const requestPasswordReset = async (email) => {
const user = await User.findOne({ email }).lean();
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}]`);
if (!user) {
return new Error('Email does not exist');
logger.warn(`[requestPasswordReset] [No user found] [Email: ${email}] [IP: ${req.ip}]`);
return {
message: 'If an account with that email exists, a password reset link has been sent to it.',
};
}
let token = await Token.findOne({ userId: user._id });
@@ -140,28 +232,31 @@ const requestPasswordReset = async (email) => {
const link = `${domains.client}/reset-password?token=${resetToken}&userId=${user._id}`;
const emailEnabled =
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
!!process.env.EMAIL_USERNAME &&
!!process.env.EMAIL_PASSWORD &&
!!process.env.EMAIL_FROM;
if (emailEnabled) {
sendEmail(
user.email,
'Password Reset Request',
{
await sendEmail({
email: user.email,
subject: 'Password Reset Request',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name,
link: link,
year: new Date().getFullYear(),
},
'requestPasswordReset.handlebars',
template: 'requestPasswordReset.handlebars',
});
logger.info(
`[requestPasswordReset] Link emailed. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
);
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.',
};
};
/**
@@ -186,39 +281,38 @@ const resetPassword = async (userId, token, password) => {
}
const hash = bcrypt.hashSync(password, 10);
const user = await updateUser(userId, { password: hash });
await User.updateOne({ _id: userId }, { $set: { password: hash } }, { new: true });
const user = await User.findById({ _id: userId });
sendEmail(
user.email,
'Password Reset Successfully',
{
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name,
year: new Date().getFullYear(),
},
'passwordReset.handlebars',
);
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 passwordResetToken.deleteOne();
logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
return { message: 'Password reset was successful' };
};
/**
* Set Auth Tokens
*
* @param {String} userId
* @param {String | ObjectId} userId
* @param {Object} res
* @param {String} sessionId
* @returns
*/
const setAuthTokens = async (userId, res, sessionId = null) => {
try {
const user = await User.findOne({ _id: userId });
const token = await user.generateToken();
const user = await getUserById(userId);
const token = await generateToken(user);
let session;
let refreshTokenExpires;
@@ -248,11 +342,70 @@ 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=${encodeURIComponent(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 = {
registerUser,
logoutUser,
verifyEmail,
registerUser,
setAuthTokens,
resetPassword,
isDomainAllowed,
requestPasswordReset,
resetPassword,
setAuthTokens,
resendVerificationEmail,
};

View File

@@ -88,7 +88,17 @@ const updateUserPluginAuth = async (userId, authField, pluginKey, value) => {
}
};
const deleteUserPluginAuth = async (userId, authField) => {
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;
}
}
try {
return await PluginAuth.deleteOne({ userId, authField });
} catch (err) {

View File

@@ -497,6 +497,34 @@ 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 ------------------> */
/**
@@ -519,7 +547,8 @@ class StreamRunManager {
};
});
const { tool_outputs } = await processRequiredActions(this, actions);
const { tool_outputs: preliminaryOutputs } = await processRequiredActions(this, actions);
const tool_outputs = this.checkMissingOutputs(preliminaryOutputs, actions);
/** @type {AssistantStream | undefined} */
let toolRun;
try {

View File

@@ -1,6 +1,6 @@
const { ErrorTypes } = require('librechat-data-provider');
const { encrypt, decrypt } = require('~/server/utils');
const { User, Key } = require('~/models');
const { updateUser, Key } = require('~/models');
const { logger } = require('~/config');
/**
@@ -16,16 +16,13 @@ const { logger } = require('~/config');
*/
const updateUserPluginsService = async (user, pluginKey, action) => {
try {
const userPlugins = user.plugins || [];
if (action === 'install') {
return await User.updateOne(
{ _id: user._id },
{ $set: { plugins: [...user.plugins, pluginKey] } },
);
return await updateUser(user._id, { plugins: [...userPlugins, pluginKey] });
} else if (action === 'uninstall') {
return await User.updateOne(
{ _id: user._id },
{ $set: { plugins: user.plugins.filter((plugin) => plugin !== pluginKey) } },
);
return await updateUser(user._id, {
plugins: userPlugins.filter((plugin) => plugin !== pluginKey),
});
}
} catch (err) {
logger.error('[updateUserPluginsService]', err);
@@ -167,11 +164,11 @@ const checkUserKeyExpiry = (expiresAt, endpoint) => {
};
module.exports = {
updateUserPluginsService,
getUserKey,
getUserKeyValues,
getUserKeyExpiry,
updateUserKey,
deleteUserKey,
getUserKeyValues,
getUserKeyExpiry,
checkUserKeyExpiry,
updateUserPluginsService,
};

View File

@@ -3,6 +3,7 @@ const {
deprecatedAzureVariables,
conflictingAzureVariables,
} = require('librechat-data-provider');
const { isEnabled, checkEmailConfig } = require('~/server/utils');
const { logger } = require('~/config');
const secretDefaults = {
@@ -49,6 +50,8 @@ function checkVariables() {
Please use the config (\`librechat.yaml\`) file for setting up OpenRouter, and use \`OPENROUTER_KEY\` or another environment variable instead.`,
);
}
checkPasswordReset();
}
/**
@@ -107,4 +110,25 @@ 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 };

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,14 @@
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 {import('agenda').Job} job - The job object.
* @param {Function} done - The done function.
* @param {{ filepath, requestUserId }} job - The job object.
*/
const importConversationJob = async (job, done) => {
const { filepath, requestUserId } = job.attrs.data;
const importConversations = async (job) => {
const { filepath, requestUserId } = job;
try {
logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`);
const fileData = await fs.readFile(filepath, 'utf8');
@@ -22,10 +18,8 @@ const importConversationJob = async (job, done) => {
// 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);
@@ -35,7 +29,4 @@ const importConversationJob = async (job, done) => {
}
};
// Call the jobScheduler.define function at startup
jobScheduler.define(IMPORT_CONVERSATION_JOB_NAME, importConversationJob);
module.exports = { IMPORT_CONVERSATION_JOB_NAME };
module.exports = importConversations;

View File

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

View File

@@ -1,5 +1,7 @@
const importers = require('./importers');
const importConversations = require('./importConversations');
module.exports = {
...importers,
importConversations,
};

View File

@@ -2,15 +2,29 @@ const streamResponse = require('./streamResponse');
const removePorts = require('./removePorts');
const countTokens = require('./countTokens');
const handleText = require('./handleText');
const cryptoUtils = require('./crypto');
const citations = require('./citations');
const sendEmail = require('./sendEmail');
const cryptoUtils = require('./crypto');
const queue = require('./queue');
const files = require('./files');
const math = require('./math');
/**
* Check if email configuration is set
* @returns {Boolean}
*/
function checkEmailConfig() {
return (
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
!!process.env.EMAIL_USERNAME &&
!!process.env.EMAIL_PASSWORD &&
!!process.env.EMAIL_FROM
);
}
module.exports = {
...streamResponse,
checkEmailConfig,
...cryptoUtils,
...handleText,
...citations,

View File

@@ -1,99 +0,0 @@
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;

View File

@@ -5,7 +5,34 @@ const handlebars = require('handlebars');
const { isEnabled } = require('~/server/utils/handleText');
const logger = require('~/config/winston');
const sendEmail = async (email, subject, payload, template) => {
/**
* 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 }) => {
try {
const transporterOptions = {
// Use STARTTLS by default instead of obligatory TLS
@@ -58,16 +85,11 @@ const sendEmail = async (email, subject, payload, template) => {
};
// Send email
transporter.sendMail(options(), (error, info) => {
if (error) {
logger.error('[sendEmail]', error);
return error;
} else {
logger.debug('[sendEmail]', info);
return info;
}
});
return await transporter.sendMail(options());
} catch (error) {
if (throwError) {
throw error;
}
logger.error('[sendEmail]', error);
return error;
}

View File

@@ -1,50 +1,28 @@
const { Strategy: DiscordStrategy } = require('passport-discord');
const { createNewUser, handleExistingUser } = require('./process');
const { logger } = require('~/config');
const User = require('~/models/User');
const socialLogin = require('./socialLogin');
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);
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`;
}
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(
{

View File

@@ -1,39 +1,16 @@
const FacebookStrategy = require('passport-facebook').Strategy;
const { createNewUser, handleExistingUser } = require('./process');
const { logger } = require('~/config');
const User = require('~/models/User');
const socialLogin = require('./socialLogin');
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 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,
});
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);
}
};
const facebookLogin = socialLogin('facebook', getProfileDetails);
module.exports = () =>
new FacebookStrategy(

View File

@@ -1,40 +1,16 @@
const { Strategy: GitHubStrategy } = require('passport-github2');
const { createNewUser, handleExistingUser } = require('./process');
const { logger } = require('~/config');
const User = require('~/models/User');
const socialLogin = require('./socialLogin');
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 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,
});
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);
}
};
const githubLogin = socialLogin('github', getProfileDetails);
module.exports = () =>
new GitHubStrategy(

View File

@@ -1,40 +1,16 @@
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
const { createNewUser, handleExistingUser } = require('./process');
const { logger } = require('~/config');
const User = require('~/models/User');
const socialLogin = require('./socialLogin');
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 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,
});
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);
}
};
const googleLogin = socialLogin('google', getProfileDetails);
module.exports = () =>
new GoogleStrategy(

View File

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

View File

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

View File

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

View File

@@ -1,29 +1,18 @@
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');
const User = require('~/models/User');
// Unix timestamp for 2024-06-07 15:20:18 Eastern Time
const verificationEnabledTimestamp = 1717788018;
async function validateLoginRequest(req) {
const { error } = loginSchema.safeParse(req.body);
return error ? errorsToString(error.errors) : null;
}
async function findUserByEmail(email) {
return User.findOne({ email: email.trim() });
}
async function comparePassword(user, password) {
return new Promise((resolve, reject) => {
user.comparePassword(password, function (err, isMatch) {
if (err) {
return reject(err);
}
resolve(isMatch);
});
});
}
async function passportLogin(req, email, password, done) {
try {
const validationError = await validateLoginRequest(req);
@@ -33,7 +22,7 @@ async function passportLogin(req, email, password, done) {
return done(null, false, { message: validationError });
}
const user = await findUserByEmail(email);
const user = await findUser({ email: email.trim() });
if (!user) {
logError('Passport Local Strategy - User Not Found', { email });
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
@@ -47,6 +36,24 @@ 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) {

View File

@@ -1,10 +1,11 @@
const fetch = require('node-fetch');
const passport = require('passport');
const jwtDecode = require('jsonwebtoken/decode');
const { Issuer, Strategy: OpenIDStrategy } = require('openid-client');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { Issuer, Strategy: OpenIDStrategy, custom } = 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 {
@@ -67,6 +68,13 @@ 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,
@@ -88,13 +96,13 @@ async function setupOpenId() {
logger.info(`[openidStrategy] verify login openidId: ${userinfo.sub}`);
logger.debug('[openidStrategy] very login tokenset and userinfo', { tokenset, userinfo });
let user = await User.findOne({ openidId: userinfo.sub });
let user = await findUser({ openidId: userinfo.sub });
logger.info(
`[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${userinfo.sub}`,
);
if (!user) {
user = await User.findOne({ email: userinfo.email });
user = await findUser({ email: userinfo.email });
logger.info(
`[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${
userinfo.email
@@ -148,14 +156,15 @@ async function setupOpenId() {
);
if (!user) {
user = new User({
user = {
provider: 'openid',
openidId: userinfo.sub,
username,
email: userinfo.email || '',
emailVerified: userinfo.email_verified || false,
name: fullName,
});
};
user = await createUser(user, true, true);
} else {
user.provider = 'openid';
user.openidId = userinfo.sub;
@@ -163,7 +172,7 @@ async function setupOpenId() {
user.name = fullName;
}
if (userinfo.picture) {
if (userinfo.picture && !user.avatar?.includes('manual=true')) {
/** @type {string | undefined} */
const imageUrl = userinfo.picture;
@@ -177,25 +186,21 @@ async function setupOpenId() {
}
const imageBuffer = await downloadImage(imageUrl, tokenset.access_token);
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
if (imageBuffer) {
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
const imagePath = await saveBuffer({
fileName,
userId: user._id.toString(),
buffer: imageBuffer,
});
user.avatar = imagePath ?? '';
} else {
user.avatar = '';
}
} else {
user.avatar = '';
}
await user.save();
user = await updateUser(user._id, user);
logger.info(
`[openidStrategy] login success openidId: ${user.openidId} username: ${user.username} email: ${user.email}`,
`[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `,
{
user: {
openidId: user.openidId,

View File

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

View File

@@ -0,0 +1,38 @@
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;

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
"version": "0.7.2",
"version": "0.7.3",
"description": "",
"type": "module",
"scripts": {
@@ -74,6 +74,7 @@
"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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,9 @@ const SocialButton = ({ id, enabled, serverDomain, oauthPath, Icon, label }) =>
const handleMouseLeave = () => {
setIsHovered(false);
if (isPressed) {setIsPressed(false);}
if (isPressed) {
setIsPressed(false);
}
};
const handleMouseDown = () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,13 @@ import useLocalize from '~/hooks/useLocalize';
import ExportButton from './ExportButton';
import store from '~/store';
export default function ExportAndShareMenu({ className = '' }: { className?: string }) {
export default function ExportAndShareMenu({
isSharedButtonEnabled,
className = '',
}: {
isSharedButtonEnabled: boolean;
className?: string;
}) {
const localize = useLocalize();
const conversation = useRecoilValue(store.conversationByIndex(0));
@@ -41,13 +47,15 @@ export default function ExportAndShareMenu({ className = '' }: { className?: str
{conversation && conversation.conversationId && (
<>
<ExportButton conversation={conversation} setPopoverActive={setIsPopoverActive} />
<ShareButton
conversationId={conversation.conversationId}
title={conversation.title ?? ''}
appendLabel={true}
className="mb-[3.5px]"
setPopoverActive={setIsPopoverActive}
/>
{isSharedButtonEnabled && (
<ShareButton
conversationId={conversation.conversationId}
title={conversation.title ?? ''}
appendLabel={true}
className="mb-[3.5px]"
setPopoverActive={setIsPopoverActive}
/>
)}
</>
)}
</DropDownMenu>

View File

@@ -1,4 +1,6 @@
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';
@@ -32,22 +34,47 @@ export default function Footer({ className }: { className?: string }) {
</a>
);
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>
);
if (config?.analyticsGtmId) {
const tagManagerArgs = {
gtmId: config?.analyticsGtmId,
};
TagManager.initialize(tagManagerArgs);
}
const footerElements = [mainContentRender, privacyPolicyRender, termsOfServiceRender].filter(
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(
Boolean,
);
@@ -55,7 +82,7 @@ export default function Footer({ className }: { className?: string }) {
<div
className={
className ||
'relative flex items-center justify-center gap-2 px-2 py-2 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-center text-xs text-gray-600 dark:text-gray-300 md:px-[60px]'
}
>
{footerElements.map((contentRender, index) => {

View File

@@ -30,9 +30,16 @@ export default function Header() {
{modelSpecs?.length > 0 && <ModelSpecsMenu modelSpecs={modelSpecs} />}
{<HeaderOptions interfaceConfig={interfaceConfig} />}
{interfaceConfig.presets && <PresetsMenu />}
{isSmallScreen && <ExportAndShareMenu className="pl-0" />}
{isSmallScreen && (
<ExportAndShareMenu
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
className="pl-0"
/>
)}
</div>
{!isSmallScreen && <ExportAndShareMenu />}
{!isSmallScreen && (
<ExportAndShareMenu isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false} />
)}
</div>
{/* Empty div for spacing */}
<div />

View File

@@ -1,6 +1,6 @@
import { useForm } from 'react-hook-form';
import { useRecoilState, useRecoilValue } from 'recoil';
import { memo, useCallback, useRef, useMemo } from 'react';
import { memo, useCallback, useRef, useMemo, useState, useEffect } from 'react';
import {
supportsFiles,
mergeFileConfig,
@@ -8,6 +8,7 @@ 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';
@@ -56,6 +57,14 @@ 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(
@@ -65,8 +74,9 @@ const ChatForm = ({ index = 0 }) => {
}
ask({ text: data.text });
methods.reset();
clearDraft();
},
[ask, methods],
[ask, methods, clearDraft],
);
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };

View File

@@ -83,7 +83,7 @@ export default function FileRow({
return (
<Image
key={index}
url={file.preview}
url={file.preview || file.filepath}
onDelete={handleDelete}
progress={file.progress}
source={file.source}

View File

@@ -55,14 +55,16 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
className="h-2/3 w-2/3"
size={41}
/>
<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>
{!!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>
)}
</div>
{assistantName ? (
<div className="flex flex-col items-center gap-0 p-2">

View File

@@ -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, removeFocusOutlines } from '~/utils';
import { cn, removeFocusRings } from '~/utils';
import { useChatContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import Container from './Container';
@@ -111,36 +111,42 @@ const EditMessage = ({
return (
<Container message={message}>
<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();
<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();
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}
/>
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>
<div className="mt-2 flex w-full justify-center text-center">
<button
className="btn btn-primary relative mr-2"

View File

@@ -5,6 +5,7 @@ import Plugin from '~/components/Messages/Content/Plugin';
import Error from '~/components/Messages/Content/Error';
import { DelayedRender } from '~/components/ui';
import EditMessage from './EditMessage';
import { useLocalize } from '~/hooks';
import Container from './Container';
import Markdown from './Markdown';
import { cn } from '~/utils';
@@ -14,6 +15,38 @@ export const ErrorMessage = ({
message,
className = '',
}: Pick<TDisplayProps, 'text' | 'className' | 'message'>) => {
const localize = useLocalize();
if (text === 'Error connecting to server, try refreshing the page.') {
console.log('error message', message);
return (
<Suspense
fallback={
<div className="text-message mb-[0.625rem] mt-1 flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto">
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
<div className="absolute">
<p className="relative">
<span className="result-thinking" />
</p>
</div>
</div>
</div>
}
>
<DelayedRender delay={5500}>
<Container message={message}>
<div
className={cn(
'rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
className,
)}
>
{localize('com_ui_error_connection')}
</div>
</Container>
</DelayedRender>
</Suspense>
);
}
return (
<Container message={message}>
<div

View File

@@ -58,7 +58,11 @@ export default function HoverButtons({
return null;
}
const { isCreatedByUser } = message;
const { isCreatedByUser, error } = message;
if (error) {
return null;
}
const onEdit = () => {
if (isEditing) {

View File

@@ -22,6 +22,7 @@ export default function Message(props: TMessageProps) {
edit,
index,
isLast,
assistant,
enterEdit,
handleScroll,
conversation,
@@ -44,6 +45,8 @@ export default function Message(props: TMessageProps) {
let messageLabel = '';
if (isCreatedByUser) {
messageLabel = UsernameDisplay ? user?.name || user?.username : localize('com_user_message');
} else if (assistant) {
messageLabel = assistant.name ?? 'Assistant';
} else {
messageLabel = message.sender;
}
@@ -61,7 +64,7 @@ export default function Message(props: TMessageProps) {
<div>
<div className="pt-0.5">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<Icon message={message} conversation={conversation} />
<Icon message={message} conversation={conversation} assistant={assistant} />
</div>
</div>
</div>

View File

@@ -21,7 +21,8 @@ const Conversations = ({
);
const firstTodayConvoId = useMemo(
() =>
conversations.find((convo) => convo && isToday(parseISO(convo.updatedAt)))?.conversationId,
conversations.find((convo) => convo && convo.updatedAt && isToday(parseISO(convo.updatedAt)))
?.conversationId,
[conversations],
);

View File

@@ -1,7 +1,7 @@
import { useRecoilValue } from 'recoil';
import { useParams } from 'react-router-dom';
import { useState, useRef, useMemo } from 'react';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import { useGetEndpointsQuery, useGetStartupConfig } 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,13 +126,15 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
setIsPopoverActive={setIsPopoverActive}
>
<DropDownMenu>
<ShareButton
conversationId={conversationId}
title={title}
appendLabel={true}
className="mb-[3.5px]"
setPopoverActive={setIsPopoverActive}
/>
{startupConfig && startupConfig.sharedLinksEnabled && (
<ShareButton
conversationId={conversationId}
title={title}
appendLabel={true}
className="mb-[3.5px]"
setPopoverActive={setIsPopoverActive}
/>
)}
<RenameButton
renaming={renaming}

View File

@@ -1,23 +0,0 @@
import { Constants } from 'librechat-data-provider';
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
import { useLocalize } from '~/hooks';
export default function Footer() {
const { data: config } = useGetStartupConfig();
const localize = useLocalize();
return (
<div className="hidden px-3 pb-1 pt-2 text-center text-xs text-black/50 dark:text-white/50 md:block md:px-4 md:pb-4 md:pt-3">
{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_pay_per_call')}
</>
)}
</div>
);
}

View File

@@ -15,7 +15,6 @@ const ClearConvos = ({ open, onOpenChange }) => {
// Clear all conversations
const clearConvos = () => {
if (confirmClear) {
console.log('Clearing conversations...');
clearConvosMutation.mutate(
{},
{

Some files were not shown because too many files have changed in this diff Show More