dify/web/app/account/(commonLayout)/account-page/email-change-modal.tsx
2026-07-17 06:05:20 +00:00

428 lines
15 KiB
TypeScript

import type { ResponseError } from '@/service/fetch'
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'
import { useRouter } from '@/next/navigation'
import { checkEmailExisted, resetEmail, sendVerifyCode, verifyEmail } from '@/service/common'
import { useLogout } from '@/service/use-common'
import { asyncRunSafe } from '@/utils'
type Props = {
onClose: () => void
email: string
}
const STEP = {
start: 'start',
verifyOrigin: 'verifyOrigin',
newEmail: 'newEmail',
verifyNew: 'verifyNew',
} as const
type Step = (typeof STEP)[keyof typeof STEP]
const emailPattern = /^[\w.!#$%&'*+\-/=?^`{|}~]+@(?:[\w-]+\.)+[\w-]{2,}$/
type FetchResponseError = {
status: number
json: () => Promise<ResponseError>
}
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>(STEP.start)
const [code, setCode] = useState<string>('')
const [mail, setMail] = useState<string>('')
const [time, setTime] = useState<number>(0)
const [stepToken, setStepToken] = useState<string>('')
const [newEmailExited, setNewEmailExited] = useState<boolean>(false)
const [unAvailableEmail, setUnAvailableEmail] = useState<boolean>(false)
const [isCheckingEmail, setIsCheckingEmail] = useState<boolean>(false)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const latestEmailRef = useRef<string>('')
const clearCountdown = useCallback(() => {
if (!timerRef.current) return
clearInterval(timerRef.current)
timerRef.current = null
}, [])
useEffect(() => clearCountdown, [clearCountdown])
const startCount = () => {
clearCountdown()
setTime(60)
timerRef.current = setInterval(() => {
setTime((prev) => {
if (prev <= 1) {
clearCountdown()
return 0
}
return prev - 1
})
}, 1000)
}
const sendEmail = async (email: string, isOrigin: boolean, token?: string) => {
try {
const res = await sendVerifyCode({
email,
phase: isOrigin ? 'old_email' : 'new_email',
token,
})
startCount()
if (res.data) setStepToken(res.data)
} catch (error) {
toast.error(`Error sending verification code: ${getErrorMessage(error)}`)
}
}
const verifyEmailAddress = async (
email: string,
code: string,
token: string,
callback?: (token: string) => void,
) => {
try {
const res = await verifyEmail({
email,
code,
token,
})
if (res.is_valid) {
setStepToken(res.token)
callback?.(res.token)
} else {
toast.error('Verifying email failed')
}
} catch (error) {
toast.error(`Error verifying email: ${getErrorMessage(error)}`)
}
}
const sendCodeToOriginEmail = async () => {
await sendEmail(email, true)
setStep(STEP.verifyOrigin)
}
const handleVerifyOriginEmail = async () => {
await verifyEmailAddress(email, code, stepToken, () => setStep(STEP.newEmail))
setCode('')
}
const isValidEmail = (email: string): boolean => {
return emailPattern.test(email)
}
const checkNewEmailExisted = async (email: string) => {
setIsCheckingEmail(true)
try {
await checkEmailExisted({
email,
})
if (latestEmailRef.current !== email) return
setNewEmailExited(false)
setUnAvailableEmail(false)
} catch (e: unknown) {
if (latestEmailRef.current !== email) return
if (isFetchResponseError(e) && e.status === 400) {
const [, errRespData] = await asyncRunSafe<ResponseError>(e.json())
const { code } = errRespData || {}
if (code === 'email_already_in_use') setNewEmailExited(true)
if (code === 'account_in_freeze') setUnAvailableEmail(true)
}
} finally {
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)
setUnAvailableEmail(false)
if (isValidEmail(normalizedMailAddress)) {
setIsCheckingEmail(true)
checkNewEmailExistedDebounced(normalizedMailAddress)
return
}
cancelCheckNewEmailExisted()
setIsCheckingEmail(false)
}
const sendCodeToNewEmail = async () => {
const normalizedMail = mail.trim()
if (!isValidEmail(normalizedMail)) {
toast.error('Invalid email format')
return
}
await sendEmail(normalizedMail, false, stepToken)
setStep(STEP.verifyNew)
}
const { mutateAsync: logout } = useLogout()
const handleLogout = async () => {
await logout()
// Tokens are now stored in cookies and cleared by backend
router.push('/signin')
}
const updateEmail = async (lastToken: string) => {
try {
await resetEmail({
new_email: mail.trim(),
token: lastToken,
})
handleLogout()
} catch (error) {
toast.error(`Error changing email: ${getErrorMessage(error)}`)
}
}
const submitNewEmail = async () => {
await verifyEmailAddress(mail.trim(), code, stepToken, updateEmail)
}
const normalizedMail = mail.trim()
const isMailValid = isValidEmail(normalizedMail)
const isSendCodeDisabled =
!normalizedMail || newEmailExited || unAvailableEmail || isCheckingEmail || !isMailValid
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="w-105! p-6!">
<div className="absolute top-5 right-5 cursor-pointer p-1.5" onClick={onClose}>
<RiCloseLine className="size-5 text-text-tertiary" />
</div>
{step === STEP.start && (
<>
<div className="pb-3 title-2xl-semi-bold text-text-primary">
{t(($) => $['account.changeEmail.title'], { ns: 'common' })}
</div>
<div className="space-y-0.5 pt-1 pb-2">
<div className="body-md-medium text-text-warning">
{t(($) => $['account.changeEmail.authTip'], { ns: 'common' })}
</div>
<div className="body-md-regular text-text-secondary">
<Trans
i18nKey={($) => $['account.changeEmail.content1']}
ns="common"
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
values={{ email }}
/>
</div>
</div>
<div className="pt-3"></div>
<div className="space-y-2">
<Button className="w-full!" variant="primary" onClick={sendCodeToOriginEmail}>
{t(($) => $['account.changeEmail.sendVerifyCode'], { ns: 'common' })}
</Button>
<Button className="w-full!" onClick={onClose}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
</div>
</>
)}
{step === STEP.verifyOrigin && (
<>
<div className="pb-3 title-2xl-semi-bold text-text-primary">
{t(($) => $['account.changeEmail.verifyEmail'], { ns: 'common' })}
</div>
<div className="space-y-0.5 pt-1 pb-2">
<div className="body-md-regular text-text-secondary">
<Trans
i18nKey={($) => $['account.changeEmail.content2']}
ns="common"
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
values={{ email }}
/>
</div>
</div>
<div className="pt-3">
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t(($) => $['account.changeEmail.codeLabel'], { ns: 'common' })}
</div>
<Input
className="w-full!"
placeholder={t(($) => $['account.changeEmail.codePlaceholder'], { ns: 'common' })}
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
/>
</div>
<div className="mt-3 space-y-2">
<Button
disabled={code.length !== 6}
className="w-full!"
variant="primary"
onClick={handleVerifyOriginEmail}
>
{t(($) => $['account.changeEmail.continue'], { ns: 'common' })}
</Button>
<Button className="w-full!" onClick={onClose}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
</div>
<div className="mt-3 flex items-center gap-1 system-xs-regular text-text-tertiary">
<span>{t(($) => $['account.changeEmail.resendTip'], { ns: 'common' })}</span>
{time > 0 && (
<span>
{t(($) => $['account.changeEmail.resendCount'], { ns: 'common', count: time })}
</span>
)}
{!time && (
<span
onClick={sendCodeToOriginEmail}
className="cursor-pointer system-xs-medium text-text-accent-secondary"
>
{t(($) => $['account.changeEmail.resend'], { ns: 'common' })}
</span>
)}
</div>
</>
)}
{step === STEP.newEmail && (
<>
<div className="pb-3 title-2xl-semi-bold text-text-primary">
{t(($) => $['account.changeEmail.newEmail'], { ns: 'common' })}
</div>
<div className="space-y-0.5 pt-1 pb-2">
<div className="body-md-regular text-text-secondary">
{t(($) => $['account.changeEmail.content3'], { ns: 'common' })}
</div>
</div>
<div className="pt-3">
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t(($) => $['account.changeEmail.emailLabel'], { ns: 'common' })}
</div>
<Input
className="w-full!"
placeholder={t(($) => $['account.changeEmail.emailPlaceholder'], { ns: 'common' })}
value={mail}
onChange={(e) => handleNewEmailValueChange(e.target.value)}
destructive={newEmailExited || unAvailableEmail}
/>
{newEmailExited && (
<div className="mt-1 py-0.5 body-xs-regular text-text-destructive">
{t(($) => $['account.changeEmail.existingEmail'], { ns: 'common' })}
</div>
)}
{unAvailableEmail && (
<div className="mt-1 py-0.5 body-xs-regular text-text-destructive">
{t(($) => $['account.changeEmail.unAvailableEmail'], { ns: 'common' })}
</div>
)}
</div>
<div className="mt-3 space-y-2">
<Button
disabled={isSendCodeDisabled}
className="w-full!"
variant="primary"
onClick={sendCodeToNewEmail}
>
{t(($) => $['account.changeEmail.sendVerifyCode'], { ns: 'common' })}
</Button>
<Button className="w-full!" onClick={onClose}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
</div>
</>
)}
{step === STEP.verifyNew && (
<>
<div className="pb-3 title-2xl-semi-bold text-text-primary">
{t(($) => $['account.changeEmail.verifyNew'], { ns: 'common' })}
</div>
<div className="space-y-0.5 pt-1 pb-2">
<div className="body-md-regular text-text-secondary">
<Trans
i18nKey={($) => $['account.changeEmail.content4']}
ns="common"
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
values={{ email: mail }}
/>
</div>
</div>
<div className="pt-3">
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t(($) => $['account.changeEmail.codeLabel'], { ns: 'common' })}
</div>
<Input
className="w-full!"
placeholder={t(($) => $['account.changeEmail.codePlaceholder'], { ns: 'common' })}
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
/>
</div>
<div className="mt-3 space-y-2">
<Button
disabled={code.length !== 6}
className="w-full!"
variant="primary"
onClick={submitNewEmail}
>
{t(($) => $['account.changeEmail.changeTo'], { ns: 'common', email: mail })}
</Button>
<Button className="w-full!" onClick={onClose}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
</div>
<div className="mt-3 flex items-center gap-1 system-xs-regular text-text-tertiary">
<span>{t(($) => $['account.changeEmail.resendTip'], { ns: 'common' })}</span>
{time > 0 && (
<span>
{t(($) => $['account.changeEmail.resendCount'], { ns: 'common', count: time })}
</span>
)}
{!time && (
<span
onClick={sendCodeToNewEmail}
className="cursor-pointer system-xs-medium text-text-accent-secondary"
>
{t(($) => $['account.changeEmail.resend'], { ns: 'common' })}
</span>
)}
</div>
</>
)}
</DialogContent>
</Dialog>
)
}
export default EmailChangeModal