🔒 fix: resolve session persistence post password reset (#5077)

*  feat: Implement session management with CRUD operations and integrate into user workflows

*  refactor: Update session model import paths and enhance session creation logic in AuthService

*  refactor: Validate session and user ID formats in session management functions

*  style: Enhance UI components with improved styling and accessibility features

* chore: Update login form tests to use getByTestId instead of getByRole, remove console.log()

* chore: Update login form tests to use getByTestId instead of getByRole

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2024-12-23 11:12:07 +01:00 committed by GitHub
parent 9bca2ae953
commit bdb222d5f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 402 additions and 116 deletions

View file

@ -1,7 +1,7 @@
const { ViolationTypes } = require('librechat-data-provider');
const { isEnabled, math, removePorts } = require('~/server/utils');
const { deleteAllUserSessions } = require('~/models');
const getLogStores = require('./getLogStores');
const Session = require('~/models/Session');
const { logger } = require('~/config');
const { BAN_VIOLATIONS, BAN_INTERVAL } = process.env ?? {};
@ -46,7 +46,7 @@ const banViolation = async (req, res, errorMessage) => {
return;
}
await Session.deleteAllUserSessions(user_id);
await deleteAllUserSessions({ userId: user_id });
res.clearCookie('refreshToken');
const banLogs = getLogStores(ViolationTypes.BAN);

View file

@ -1,75 +1,275 @@
const mongoose = require('mongoose');
const signPayload = require('~/server/services/signPayload');
const { hashToken } = require('~/server/utils/crypto');
const sessionSchema = require('./schema/session');
const { logger } = require('~/config');
const Session = mongoose.model('Session', sessionSchema);
const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
const expires = eval(REFRESH_TOKEN_EXPIRY) ?? 1000 * 60 * 60 * 24 * 7;
const expires = eval(REFRESH_TOKEN_EXPIRY) ?? 1000 * 60 * 60 * 24 * 7; // 7 days default
const sessionSchema = mongoose.Schema({
refreshTokenHash: {
type: String,
required: true,
},
expiration: {
type: Date,
required: true,
expires: 0,
},
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
});
/**
* Error class for Session-related errors
*/
class SessionError extends Error {
constructor(message, code = 'SESSION_ERROR') {
super(message);
this.name = 'SessionError';
this.code = code;
}
}
/**
* Creates a new session for a user
* @param {string} userId - The ID of the user
* @param {Object} options - Additional options for session creation
* @param {Date} options.expiration - Custom expiration date
* @returns {Promise<{session: Session, refreshToken: string}>}
* @throws {SessionError}
*/
const createSession = async (userId, options = {}) => {
if (!userId) {
throw new SessionError('User ID is required', 'INVALID_USER_ID');
}
sessionSchema.methods.generateRefreshToken = async function () {
try {
let expiresIn;
if (this.expiration) {
expiresIn = this.expiration.getTime();
} else {
expiresIn = Date.now() + expires;
this.expiration = new Date(expiresIn);
const session = new Session({
user: userId,
expiration: options.expiration || new Date(Date.now() + expires),
});
const refreshToken = await generateRefreshToken(session);
return { session, refreshToken };
} catch (error) {
logger.error('[createSession] Error creating session:', error);
throw new SessionError('Failed to create session', 'CREATE_SESSION_FAILED');
}
};
/**
* Finds a session by various parameters
* @param {Object} params - Search parameters
* @param {string} [params.refreshToken] - The refresh token to search by
* @param {string} [params.userId] - The user ID to search by
* @param {string} [params.sessionId] - The session ID to search by
* @param {Object} [options] - Additional options
* @param {boolean} [options.lean=true] - Whether to return plain objects instead of documents
* @returns {Promise<Session|null>}
* @throws {SessionError}
*/
const findSession = async (params, options = { lean: true }) => {
try {
const query = {};
if (!params.refreshToken && !params.userId && !params.sessionId) {
throw new SessionError('At least one search parameter is required', 'INVALID_SEARCH_PARAMS');
}
if (params.refreshToken) {
const tokenHash = await hashToken(params.refreshToken);
query.refreshTokenHash = tokenHash;
}
if (params.userId) {
query.user = params.userId;
}
if (params.sessionId) {
const sessionId = params.sessionId.sessionId || params.sessionId;
if (!mongoose.Types.ObjectId.isValid(sessionId)) {
throw new SessionError('Invalid session ID format', 'INVALID_SESSION_ID');
}
query._id = sessionId;
}
// Add expiration check to only return valid sessions
query.expiration = { $gt: new Date() };
const sessionQuery = Session.findOne(query);
if (options.lean) {
return await sessionQuery.lean();
}
return await sessionQuery.exec();
} catch (error) {
logger.error('[findSession] Error finding session:', error);
throw new SessionError('Failed to find session', 'FIND_SESSION_FAILED');
}
};
/**
* Updates session expiration
* @param {Session|string} session - The session or session ID to update
* @param {Date} [newExpiration] - Optional new expiration date
* @returns {Promise<Session>}
* @throws {SessionError}
*/
const updateExpiration = async (session, newExpiration) => {
try {
const sessionDoc = typeof session === 'string' ? await Session.findById(session) : session;
if (!sessionDoc) {
throw new SessionError('Session not found', 'SESSION_NOT_FOUND');
}
sessionDoc.expiration = newExpiration || new Date(Date.now() + expires);
return await sessionDoc.save();
} catch (error) {
logger.error('[updateExpiration] Error updating session:', error);
throw new SessionError('Failed to update session expiration', 'UPDATE_EXPIRATION_FAILED');
}
};
/**
* Deletes a session by refresh token or session ID
* @param {Object} params - Delete parameters
* @param {string} [params.refreshToken] - The refresh token of the session to delete
* @param {string} [params.sessionId] - The ID of the session to delete
* @returns {Promise<Object>}
* @throws {SessionError}
*/
const deleteSession = async (params) => {
try {
if (!params.refreshToken && !params.sessionId) {
throw new SessionError(
'Either refreshToken or sessionId is required',
'INVALID_DELETE_PARAMS',
);
}
const query = {};
if (params.refreshToken) {
query.refreshTokenHash = await hashToken(params.refreshToken);
}
if (params.sessionId) {
query._id = params.sessionId;
}
const result = await Session.deleteOne(query);
if (result.deletedCount === 0) {
logger.warn('[deleteSession] No session found to delete');
}
return result;
} catch (error) {
logger.error('[deleteSession] Error deleting session:', error);
throw new SessionError('Failed to delete session', 'DELETE_SESSION_FAILED');
}
};
/**
* Deletes all sessions for a user
* @param {string} userId - The ID of the user
* @param {Object} [options] - Additional options
* @param {boolean} [options.excludeCurrentSession] - Whether to exclude the current session
* @param {string} [options.currentSessionId] - The ID of the current session to exclude
* @returns {Promise<Object>}
* @throws {SessionError}
*/
const deleteAllUserSessions = async (userId, options = {}) => {
try {
if (!userId) {
throw new SessionError('User ID is required', 'INVALID_USER_ID');
}
// Extract userId if it's passed as an object
const userIdString = userId.userId || userId;
if (!mongoose.Types.ObjectId.isValid(userIdString)) {
throw new SessionError('Invalid user ID format', 'INVALID_USER_ID_FORMAT');
}
const query = { user: userIdString };
if (options.excludeCurrentSession && options.currentSessionId) {
query._id = { $ne: options.currentSessionId };
}
const result = await Session.deleteMany(query);
if (result.deletedCount > 0) {
logger.debug(
`[deleteAllUserSessions] Deleted ${result.deletedCount} sessions for user ${userIdString}.`,
);
}
return result;
} catch (error) {
logger.error('[deleteAllUserSessions] Error deleting user sessions:', error);
throw new SessionError('Failed to delete user sessions', 'DELETE_ALL_SESSIONS_FAILED');
}
};
/**
* Generates a refresh token for a session
* @param {Session} session - The session to generate a token for
* @returns {Promise<string>}
* @throws {SessionError}
*/
const generateRefreshToken = async (session) => {
if (!session || !session.user) {
throw new SessionError('Invalid session object', 'INVALID_SESSION');
}
try {
const expiresIn = session.expiration ? session.expiration.getTime() : Date.now() + expires;
if (!session.expiration) {
session.expiration = new Date(expiresIn);
}
const refreshToken = await signPayload({
payload: { id: this.user },
payload: {
id: session.user,
sessionId: session._id,
},
secret: process.env.JWT_REFRESH_SECRET,
expirationTime: Math.floor((expiresIn - Date.now()) / 1000),
});
this.refreshTokenHash = await hashToken(refreshToken);
await this.save();
session.refreshTokenHash = await hashToken(refreshToken);
await session.save();
return refreshToken;
} catch (error) {
logger.error(
'Error generating refresh token. Is a `JWT_REFRESH_SECRET` set in the .env file?\n\n',
error,
);
throw error;
logger.error('[generateRefreshToken] Error generating refresh token:', error);
throw new SessionError('Failed to generate refresh token', 'GENERATE_TOKEN_FAILED');
}
};
sessionSchema.statics.deleteAllUserSessions = async function (userId) {
/**
* Counts active sessions for a user
* @param {string} userId - The ID of the user
* @returns {Promise<number>}
* @throws {SessionError}
*/
const countActiveSessions = async (userId) => {
try {
if (!userId) {
return;
}
const result = await this.deleteMany({ user: userId });
if (result && result?.deletedCount > 0) {
logger.debug(
`[deleteAllUserSessions] Deleted ${result.deletedCount} sessions for user ${userId}.`,
);
throw new SessionError('User ID is required', 'INVALID_USER_ID');
}
return await Session.countDocuments({
user: userId,
expiration: { $gt: new Date() },
});
} catch (error) {
logger.error('[deleteAllUserSessions] Error in deleting user sessions:', error);
throw error;
logger.error('[countActiveSessions] Error counting active sessions:', error);
throw new SessionError('Failed to count active sessions', 'COUNT_SESSIONS_FAILED');
}
};
const Session = mongoose.model('Session', sessionSchema);
module.exports = Session;
module.exports = {
createSession,
findSession,
updateExpiration,
deleteSession,
deleteAllUserSessions,
generateRefreshToken,
countActiveSessions,
SessionError,
};

View file

@ -26,10 +26,18 @@ const {
deleteMessagesSince,
deleteMessages,
} = require('./Message');
const {
createSession,
findSession,
updateExpiration,
deleteSession,
deleteAllUserSessions,
generateRefreshToken,
countActiveSessions,
} = require('./Session');
const { getConvoTitle, getConvo, saveConvo, deleteConvos } = require('./Conversation');
const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset');
const { createToken, findToken, updateToken, deleteTokens } = require('./Token');
const Session = require('./Session');
const Balance = require('./Balance');
const User = require('./User');
const Key = require('./Key');
@ -75,8 +83,15 @@ module.exports = {
updateToken,
deleteTokens,
createSession,
findSession,
updateExpiration,
deleteSession,
deleteAllUserSessions,
generateRefreshToken,
countActiveSessions,
User,
Key,
Session,
Balance,
};

View file

@ -0,0 +1,20 @@
const mongoose = require('mongoose');
const sessionSchema = mongoose.Schema({
refreshTokenHash: {
type: String,
required: true,
},
expiration: {
type: Date,
required: true,
expires: 0,
},
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
});
module.exports = sessionSchema;

View file

@ -6,8 +6,8 @@ const {
setAuthTokens,
requestPasswordReset,
} = require('~/server/services/AuthService');
const { findSession, getUserById, deleteAllUserSessions } = require('~/models');
const { hashToken } = require('~/server/utils/crypto');
const { Session, getUserById } = require('~/models');
const { logger } = require('~/config');
const registrationController = async (req, res) => {
@ -45,6 +45,7 @@ const resetPasswordController = async (req, res) => {
if (resetPasswordService instanceof Error) {
return res.status(400).json(resetPasswordService);
} else {
await deleteAllUserSessions({ userId: req.body.userId });
return res.status(200).json(resetPasswordService);
}
} catch (e) {
@ -77,7 +78,7 @@ const refreshController = async (req, res) => {
const hashedToken = await hashToken(refreshToken);
// Find the session with the hashed refresh token
const session = await Session.findOne({ user: userId, refreshTokenHash: hashedToken });
const session = await findSession({ userId: userId, refreshToken: hashedToken });
if (session && session.expiration > new Date()) {
const token = await setAuthTokens(userId, res, session._id);
res.status(200).send({ token, user });

View file

@ -1,5 +1,4 @@
const {
Session,
Balance,
getFiles,
deleteFiles,
@ -7,6 +6,7 @@ const {
deletePresets,
deleteMessages,
deleteUserById,
deleteAllUserSessions,
} = require('~/models');
const User = require('~/models/User');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
@ -112,7 +112,7 @@ const deleteUserController = async (req, res) => {
try {
await deleteMessages({ user: user.id }); // delete user messages
await Session.deleteMany({ user: user.id }); // delete user sessions
await deleteAllUserSessions({ userId: 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

View file

@ -10,7 +10,15 @@ const {
generateToken,
deleteUserById,
} = require('~/models/userMethods');
const { createToken, findToken, deleteTokens, Session } = require('~/models');
const {
createToken,
findToken,
deleteTokens,
findSession,
deleteSession,
createSession,
generateRefreshToken,
} = require('~/models');
const { isEnabled, checkEmailConfig, sendEmail } = require('~/server/utils');
const { isEmailDomainAllowed } = require('~/server/services/domains');
const { registerSchema } = require('~/strategies/validators');
@ -37,10 +45,11 @@ const logoutUser = async (userId, refreshToken) => {
const hash = await hashToken(refreshToken);
// Find the session with the matching user and refreshTokenHash
const session = await Session.findOne({ user: userId, refreshTokenHash: hash });
const session = await findSession({ userId: userId, refreshToken: hash });
if (session) {
try {
await Session.deleteOne({ _id: session._id });
await deleteSession({ sessionId: session._id });
} catch (deleteErr) {
logger.error('[logoutUser] Failed to delete session.', deleteErr);
return { status: 500, message: 'Failed to delete session.' };
@ -330,18 +339,19 @@ const setAuthTokens = async (userId, res, sessionId = null) => {
const token = await generateToken(user);
let session;
let refreshToken;
let refreshTokenExpires;
if (sessionId) {
session = await Session.findById(sessionId);
refreshTokenExpires = session.expiration.getTime();
} else {
session = new Session({ user: userId });
const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
const expires = eval(REFRESH_TOKEN_EXPIRY) ?? 1000 * 60 * 60 * 24 * 7;
refreshTokenExpires = Date.now() + expires;
}
const refreshToken = await session.generateRefreshToken();
if (sessionId) {
session = await findSession({ sessionId: sessionId });
refreshTokenExpires = session.expiration.getTime();
refreshToken = await generateRefreshToken(session);
} else {
const result = await createSession(userId);
session = result.session;
refreshToken = result.refreshToken;
refreshTokenExpires = session.expiration.getTime();
}
res.cookie('refreshToken', refreshToken, {
expires: new Date(refreshTokenExpires),

View file

@ -2,7 +2,7 @@ export const ErrorMessage = ({ children }: { children: React.ReactNode }) => (
<div
role="alert"
aria-live="assertive"
className="rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200"
className="relative mt-6 rounded-lg border border-red-500/20 bg-red-50/50 px-6 py-4 text-red-700 shadow-sm transition-all dark:bg-red-950/30 dark:text-red-100"
>
{children}
</div>

View file

@ -26,7 +26,10 @@ function Login() {
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">
{' '}
{localize('com_auth_no_account')}{' '}
<a href="/register" className="p-1 text-green-500">
<a
href="/register"
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
{localize('com_auth_sign_up')}
</a>
</p>

View file

@ -153,16 +153,24 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
{renderError('password')}
</div>
{startupConfig.passwordResetEnabled && (
<a href="/forgot-password" className="text-sm text-green-500">
<a
href="/forgot-password"
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
{localize('com_auth_password_forgot')}
</a>
)}
<div className="mt-6">
<button
aria-label="Sign in"
aria-label={localize('com_auth_continue')}
data-testid="login-button"
type="submit"
className="btn-primary w-full transform rounded-2xl px-4 py-3 tracking-wide transition-colors duration-200"
className="
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
"
>
{localize('com_auth_continue')}
</button>

View file

@ -183,7 +183,12 @@ const Registration: React.FC = () => {
disabled={Object.keys(errors).length > 0}
type="submit"
aria-label="Submit registration"
className="btn-primary w-full transform rounded-2xl px-4 py-3 tracking-wide transition-colors duration-200"
className="
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
"
>
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
</button>
@ -192,7 +197,11 @@ const Registration: React.FC = () => {
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">
{localize('com_auth_already_have_account')}{' '}
<a href="/login" aria-label="Login" className="p-1 text-green-500">
<a
href="/login"
aria-label="Login"
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
{localize('com_auth_login')}
</a>
</p>

View file

@ -10,7 +10,7 @@ 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"
className="relative mt-6 rounded-lg border border-green-500/20 bg-green-50/50 px-6 py-4 text-green-700 shadow-sm transition-all dark:bg-green-950/30 dark:text-green-100"
role="alert"
>
{children}
@ -21,13 +21,14 @@ const BodyTextWrapper: FC<{ children: ReactNode }> = ({ children }) => {
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">
<div className="flex flex-col space-y-4">
<p>{localize('com_auth_reset_password_if_email_exists')}</p>
<a
className="inline-flex text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
href="/login"
>
{localize('com_auth_back_to_login')}
</a>
</span>
</div>
);
};
@ -76,12 +77,12 @@ function RequestPasswordReset() {
return (
<form
className="mt-6"
className="mt-8 space-y-6"
aria-label="Password reset form"
method="POST"
onSubmit={handleSubmit(onSubmit)}
>
<div className="mb-2">
<div className="space-y-2">
<div className="relative">
<input
type="email"
@ -105,43 +106,52 @@ function RequestPasswordReset() {
})}
aria-invalid={!!errors.email}
className="
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
peer w-full rounded-lg border border-gray-300 bg-transparent px-4 py-3
text-base text-gray-900 placeholder-transparent transition-all
focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20
dark:border-gray-700 dark:text-white dark:focus:border-green-500
"
placeholder=" "
placeholder="email@example.com"
/>
<label
htmlFor="email"
className="
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
absolute -top-2 left-2 z-10 bg-white px-2 text-sm text-gray-600
transition-all peer-placeholder-shown:top-3 peer-placeholder-shown:text-base
peer-placeholder-shown:text-gray-500 peer-focus:-top-2 peer-focus:text-sm
peer-focus:text-green-600 dark:bg-gray-900 dark:text-gray-400
dark:peer-focus:text-green-500
"
>
{localize('com_auth_email_address')}
</label>
</div>
{errors.email && (
<span role="alert" className="mt-1 text-sm text-red-500 dark:text-red-900">
<p role="alert" className="text-sm font-medium text-red-600 dark:text-red-400">
{errors.email.message}
</span>
</p>
)}
</div>
<div className="mt-6">
<div className="space-y-4">
<button
type="submit"
disabled={!!errors.email}
className="btn-primary w-full transform rounded-2xl px-4 py-3 tracking-wide transition-colors duration-200"
className="
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
"
>
{localize('com_auth_continue')}
</button>
<div className="mt-4 flex justify-center">
<a href="/login" className="text-sm text-green-500">
<a
href="/login"
className="block text-center text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
{localize('com_auth_back_to_login')}
</a>
</div>
</div>
</form>
);
}

View file

@ -35,7 +35,7 @@ function ResetPassword() {
return (
<>
<div
className="relative mb-8 mt-4 rounded border border-green-400 bg-green-100 px-4 py-3 text-center text-green-700 dark:bg-gray-900 dark:text-white"
className="relative mb-8 mt-4 rounded-2xl border border-green-400 bg-green-100 px-4 py-3 text-center text-green-700 dark:bg-gray-900 dark:text-white"
role="alert"
>
{localize('com_auth_login_with_new_password')}
@ -43,7 +43,7 @@ function ResetPassword() {
<button
onClick={() => navigate('/login')}
aria-label={localize('com_auth_sign_in')}
className="w-full transform rounded-md bg-green-500 px-4 py-3 tracking-wide text-white transition-colors duration-200 hover:bg-green-600 focus:bg-green-600 focus:outline-none"
className="w-full transform rounded-2xl bg-green-500 px-4 py-3 tracking-wide text-white transition-colors duration-200 hover:bg-green-600 focus:bg-green-600 focus:outline-none"
>
{localize('com_auth_continue')}
</button>
@ -163,7 +163,12 @@ function ResetPassword() {
disabled={!!errors.password || !!errors.confirm_password}
type="submit"
aria-label={localize('com_auth_submit_registration')}
className="btn-primary w-full transform rounded-2xl px-4 py-3 tracking-wide transition-colors duration-200"
className="
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
"
>
{localize('com_auth_continue')}
</button>

View file

@ -1,6 +1,6 @@
import reactRouter from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import { render, waitFor } from 'test/layout-test-utils';
import { getByTestId, render, waitFor } from 'test/layout-test-utils';
import * as mockDataProvider from 'librechat-data-provider/react-query';
import type { TStartupConfig } from 'librechat-data-provider';
import AuthLayout from '~/components/Auth/AuthLayout';
@ -117,7 +117,7 @@ test('renders login form', () => {
const { getByLabelText, getByRole } = setup();
expect(getByLabelText(/email/i)).toBeInTheDocument();
expect(getByLabelText(/password/i)).toBeInTheDocument();
expect(getByRole('button', { name: /Sign in/i })).toBeInTheDocument();
expect(getByTestId(document.body, 'login-button')).toBeInTheDocument();
expect(getByRole('link', { name: /Sign up/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Sign up/i })).toHaveAttribute('href', '/register');
expect(getByRole('link', { name: /Continue with Google/i })).toBeInTheDocument();
@ -144,7 +144,7 @@ test('renders login form', () => {
test('calls loginUser.mutate on login', async () => {
const mutate = jest.fn();
const { getByLabelText, getByRole } = setup({
const { getByLabelText } = setup({
// @ts-ignore - we don't need all parameters of the QueryObserverResult
useLoginUserReturnValue: {
isLoading: false,
@ -155,7 +155,7 @@ test('calls loginUser.mutate on login', async () => {
const emailInput = getByLabelText(/email/i);
const passwordInput = getByLabelText(/password/i);
const submitButton = getByRole('button', { name: /Sign in/i });
const submitButton = getByTestId(document.body, 'login-button');
await userEvent.type(emailInput, 'test@test.com');
await userEvent.type(passwordInput, 'password');
@ -165,7 +165,7 @@ test('calls loginUser.mutate on login', async () => {
});
test('Navigates to / on successful login', async () => {
const { getByLabelText, getByRole, history } = setup({
const { getByLabelText, history } = setup({
// @ts-ignore - we don't need all parameters of the QueryObserverResult
useLoginUserReturnValue: {
isLoading: false,
@ -185,7 +185,7 @@ test('Navigates to / on successful login', async () => {
const emailInput = getByLabelText(/email/i);
const passwordInput = getByLabelText(/password/i);
const submitButton = getByRole('button', { name: /Sign in/i });
const submitButton = getByTestId(document.body, 'login-button');
await userEvent.type(emailInput, 'test@test.com');
await userEvent.type(passwordInput, 'password');

View file

@ -1,4 +1,4 @@
import { render } from 'test/layout-test-utils';
import { render, getByTestId } 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';
@ -112,7 +112,7 @@ test('submits login form', async () => {
);
const emailInput = getByLabelText(/email/i);
const passwordInput = getByLabelText(/password/i);
const submitButton = getByRole('button', { name: /Sign in/i });
const submitButton = getByTestId(document.body, 'login-button');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(passwordInput, 'password');
@ -127,7 +127,7 @@ test('displays validation error messages', async () => {
);
const emailInput = getByLabelText(/email/i);
const passwordInput = getByLabelText(/password/i);
const submitButton = getByRole('button', { name: /Sign in/i });
const submitButton = getByTestId(document.body, 'login-button');
await userEvent.type(emailInput, 'test');
await userEvent.type(passwordInput, 'pass');

View file

@ -579,8 +579,6 @@ describe('Conversation Utilities with Fake Data', () => {
5,
);
console.log(normalizedData);
expect(normalizedData.pages[0].conversations).toHaveLength(0);
});

View file

@ -1,5 +1,11 @@
import connectDb from '@librechat/backend/lib/db/connectDb';
import { deleteMessages, deleteConvos, User, Session, Balance } from '@librechat/backend/models';
import {
deleteMessages,
deleteConvos,
User,
deleteAllUserSessions,
Balance,
} from '@librechat/backend/models';
import { Transaction } from '@librechat/backend/models/Transaction';
type TUser = { email: string; password: string };
@ -26,7 +32,8 @@ export default async function cleanupUser(user: TUser) {
console.log(`🤖: ✅ Deleted ${deletedMessages} remaining message(s)`);
}
await Session.deleteAllUserSessions(user);
// TODO: fix this to delete all user sessions with the user's email
await deleteAllUserSessions(user);
await User.deleteMany({ _id: user });
await Balance.deleteMany({ user });