📧 feat: email verification (#2344)

* feat: verification email

* chore: email verification invalid; localize: update

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

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

* fix: user needs to be authenticated

* style: update

* fix: registration success message and redirect logic

* refactor: use `isEnabled` in ALLOW_UNVERIFIED_EMAIL_LOGIN

* refactor: move checkEmailConfig to server/utils

* refactor: use req as param for verifyEmail function

* chore: jsdoc

* chore: remove console log

* refactor: rename `createNewUser` to `createSocialUser`

* refactor: update typing and add expiresAt field to userSchema

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

* refactor: initial email verification rewrite

* chore: typing

* refactor: registration flow rewrite

* chore: remove help center text

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

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

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

* refactor: logout on deleteUser mutatation

* refactor: Update openidStrategy login success message format

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

* refactor: move limiters to separate middleware dir

* refactor: Add limiters for email verification and password reset

* refactor: Remove getUserController and update routes and controllers accordingly

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

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

* refactor: Improve email verification process and resend option

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

* refactor: replace user authentication methods and token generation

* fix: add user.id to jwt user

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

* fix(updateUserPluginsService): ensure userPlugins variable is defined

* refactor: Delete all shared links for a specific user

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

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

---------

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

View file

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

View file

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

View file

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

View file

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

View file

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