mirror of
https://github.com/langgenius/dify.git
synced 2026-08-28 19:38:30 +08:00
428 lines
15 KiB
TypeScript
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
|