diff --git a/eslint-suppressions.json b/eslint-suppressions.json index b2a7812e63..829ee56d7e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -157,11 +157,6 @@ "count": 2 } }, - "web/app/account/(commonLayout)/account-page/email-change-modal.tsx": { - "ts/no-explicit-any": { - "count": 5 - } - }, "web/app/account/(commonLayout)/delete-account/components/verify-email.tsx": { "react/set-state-in-effect": { "count": 1 diff --git a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx index 8d8f3b8d4c..185c80fc20 100644 --- a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx +++ b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx @@ -3,6 +3,7 @@ import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' +import { useDebounceFn } from 'ahooks' import { useCallback, useEffect, useRef, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' @@ -30,10 +31,35 @@ const STEP = { type Step = typeof STEP[keyof typeof STEP] +const emailPattern = /^[\w.!#$%&'*+\-/=?^`{|}~]+@(?:[\w-]+\.)+[\w-]{2,}$/ + +type FetchResponseError = { + status: number + json: () => Promise +} + +function getErrorMessage(error: unknown) { + if (error instanceof Error) + return error.message + if (typeof error === 'object' && error !== null && 'message' in error) { + const message = (error as { message?: unknown }).message + return typeof message === 'string' ? message : '' + } + return '' +} + +function isFetchResponseError(error: unknown): error is FetchResponseError { + if (typeof error !== 'object' || error === null) + return false + + const maybeError = error as { status?: unknown, json?: unknown } + return typeof maybeError.status === 'number' && typeof maybeError.json === 'function' +} + const EmailChangeModal = ({ onClose, email }: Props) => { const { t } = useTranslation() const router = useRouter() - const [step, setStep] = useState(STEP.start) + const [step, setStep] = useState(STEP.newEmail) const [code, setCode] = useState('') const [mail, setMail] = useState('') const [time, setTime] = useState(0) @@ -42,6 +68,7 @@ const EmailChangeModal = ({ onClose, email }: Props) => { const [unAvailableEmail, setUnAvailableEmail] = useState(false) const [isCheckingEmail, setIsCheckingEmail] = useState(false) const timerRef = useRef | null>(null) + const latestEmailRef = useRef('') const clearCountdown = useCallback(() => { if (!timerRef.current) @@ -79,11 +106,11 @@ const EmailChangeModal = ({ onClose, email }: Props) => { setStepToken(res.data) } catch (error) { - toast.error(`Error sending verification code: ${error ? (error as any).message : ''}`) + toast.error(`Error sending verification code: ${getErrorMessage(error)}`) } } - const verifyEmailAddress = async (email: string, code: string, token: string, callback?: (data?: any) => void) => { + const verifyEmailAddress = async (email: string, code: string, token: string, callback?: (token: string) => void) => { try { const res = await verifyEmail({ email, @@ -99,7 +126,7 @@ const EmailChangeModal = ({ onClose, email }: Props) => { } } catch (error) { - toast.error(`Error verifying email: ${error ? (error as any).message : ''}`) + toast.error(`Error verifying email: ${getErrorMessage(error)}`) } } @@ -117,8 +144,7 @@ const EmailChangeModal = ({ onClose, email }: Props) => { } const isValidEmail = (email: string): boolean => { - const rfc5322emailRegex = /^[\w.!#$%&'*+/=?^`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i - return rfc5322emailRegex.test(email) && email.length <= 254 + return emailPattern.test(email) } const checkNewEmailExisted = async (email: string) => { @@ -127,11 +153,15 @@ const EmailChangeModal = ({ onClose, email }: Props) => { await checkEmailExisted({ email, }) + if (latestEmailRef.current !== email) + return setNewEmailExited(false) setUnAvailableEmail(false) } - catch (e: any) { - if (e.status === 400) { + catch (e: unknown) { + if (latestEmailRef.current !== email) + return + if (isFetchResponseError(e) && e.status === 400) { const [, errRespData] = await asyncRunSafe(e.json()) const { code } = errRespData || {} if (code === 'email_already_in_use') @@ -141,24 +171,41 @@ const EmailChangeModal = ({ onClose, email }: Props) => { } } finally { - setIsCheckingEmail(false) + if (latestEmailRef.current === email) + setIsCheckingEmail(false) } } + const { + run: checkNewEmailExistedDebounced, + cancel: cancelCheckNewEmailExisted, + } = useDebounceFn(checkNewEmailExisted, { wait: 500 }) + + useEffect(() => cancelCheckNewEmailExisted, [cancelCheckNewEmailExisted]) + const handleNewEmailValueChange = (mailAddress: string) => { + const normalizedMailAddress = mailAddress.trim() + latestEmailRef.current = normalizedMailAddress setMail(mailAddress) setNewEmailExited(false) - if (isValidEmail(mailAddress)) - checkNewEmailExisted(mailAddress) + setUnAvailableEmail(false) + if (isValidEmail(normalizedMailAddress)) { + setIsCheckingEmail(true) + checkNewEmailExistedDebounced(normalizedMailAddress) + return + } + cancelCheckNewEmailExisted() + setIsCheckingEmail(false) } const sendCodeToNewEmail = async () => { - if (!isValidEmail(mail)) { + const normalizedMail = mail.trim() + if (!isValidEmail(normalizedMail)) { toast.error('Invalid email format') return } await sendEmail( - mail, + normalizedMail, false, stepToken, ) @@ -178,23 +225,27 @@ const EmailChangeModal = ({ onClose, email }: Props) => { const updateEmail = async (lastToken: string) => { try { await resetEmail({ - new_email: mail, + new_email: mail.trim(), token: lastToken, }) handleLogout() } catch (error) { - toast.error(`Error changing email: ${error ? (error as any).message : ''}`) + toast.error(`Error changing email: ${getErrorMessage(error)}`) } } const submitNewEmail = async () => { - await verifyEmailAddress(mail, code, stepToken, updateEmail) + await verifyEmailAddress(mail.trim(), code, stepToken, updateEmail) } + const normalizedMail = mail.trim() + const isMailValid = isValidEmail(normalizedMail) + const isSendCodeDisabled = !normalizedMail || newEmailExited || unAvailableEmail || isCheckingEmail || !isMailValid + return ( !open && onClose()}> - +
@@ -304,7 +355,7 @@ const EmailChangeModal = ({ onClose, email }: Props) => {