You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

email-change-modal.tsx 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. import React, { useState } from 'react'
  2. import { Trans, useTranslation } from 'react-i18next'
  3. import { useRouter } from 'next/navigation'
  4. import { useContext } from 'use-context-selector'
  5. import { ToastContext } from '@/app/components/base/toast'
  6. import { RiCloseLine } from '@remixicon/react'
  7. import Modal from '@/app/components/base/modal'
  8. import Button from '@/app/components/base/button'
  9. import Input from '@/app/components/base/input'
  10. import {
  11. checkEmailExisted,
  12. logout,
  13. resetEmail,
  14. sendVerifyCode,
  15. verifyEmail,
  16. } from '@/service/common'
  17. import { noop } from 'lodash-es'
  18. import { asyncRunSafe } from '@/utils'
  19. import type { ResponseError } from '@/service/fetch'
  20. type Props = {
  21. show: boolean
  22. onClose: () => void
  23. email: string
  24. }
  25. enum STEP {
  26. start = 'start',
  27. verifyOrigin = 'verifyOrigin',
  28. newEmail = 'newEmail',
  29. verifyNew = 'verifyNew',
  30. }
  31. const EmailChangeModal = ({ onClose, email, show }: Props) => {
  32. const { t } = useTranslation()
  33. const { notify } = useContext(ToastContext)
  34. const router = useRouter()
  35. const [step, setStep] = useState<STEP>(STEP.start)
  36. const [code, setCode] = useState<string>('')
  37. const [mail, setMail] = useState<string>('')
  38. const [time, setTime] = useState<number>(0)
  39. const [stepToken, setStepToken] = useState<string>('')
  40. const [newEmailExited, setNewEmailExited] = useState<boolean>(false)
  41. const [unAvailableEmail, setUnAvailableEmail] = useState<boolean>(false)
  42. const [isCheckingEmail, setIsCheckingEmail] = useState<boolean>(false)
  43. const startCount = () => {
  44. setTime(60)
  45. const timer = setInterval(() => {
  46. setTime((prev) => {
  47. if (prev <= 0) {
  48. clearInterval(timer)
  49. return 0
  50. }
  51. return prev - 1
  52. })
  53. }, 1000)
  54. }
  55. const sendEmail = async (email: string, isOrigin: boolean, token?: string) => {
  56. try {
  57. const res = await sendVerifyCode({
  58. email,
  59. phase: isOrigin ? 'old_email' : 'new_email',
  60. token,
  61. })
  62. startCount()
  63. if (res.data)
  64. setStepToken(res.data)
  65. }
  66. catch (error) {
  67. notify({
  68. type: 'error',
  69. message: `Error sending verification code: ${error ? (error as any).message : ''}`,
  70. })
  71. }
  72. }
  73. const verifyEmailAddress = async (email: string, code: string, token: string, callback?: (data?: any) => void) => {
  74. try {
  75. const res = await verifyEmail({
  76. email,
  77. code,
  78. token,
  79. })
  80. if (res.is_valid) {
  81. setStepToken(res.token)
  82. callback?.(res.token)
  83. }
  84. else {
  85. notify({
  86. type: 'error',
  87. message: 'Verifying email failed',
  88. })
  89. }
  90. }
  91. catch (error) {
  92. notify({
  93. type: 'error',
  94. message: `Error verifying email: ${error ? (error as any).message : ''}`,
  95. })
  96. }
  97. }
  98. const sendCodeToOriginEmail = async () => {
  99. await sendEmail(
  100. email,
  101. true,
  102. )
  103. setStep(STEP.verifyOrigin)
  104. }
  105. const handleVerifyOriginEmail = async () => {
  106. await verifyEmailAddress(email, code, stepToken, () => setStep(STEP.newEmail))
  107. setCode('')
  108. }
  109. const isValidEmail = (email: string): boolean => {
  110. const rfc5322emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
  111. return rfc5322emailRegex.test(email) && email.length <= 254
  112. }
  113. const checkNewEmailExisted = async (email: string) => {
  114. setIsCheckingEmail(true)
  115. try {
  116. await checkEmailExisted({
  117. email,
  118. })
  119. setNewEmailExited(false)
  120. setUnAvailableEmail(false)
  121. }
  122. catch (e: any) {
  123. if (e.status === 400) {
  124. const [, errRespData] = await asyncRunSafe<ResponseError>(e.json())
  125. const { code } = errRespData || {}
  126. if (code === 'email_already_in_use')
  127. setNewEmailExited(true)
  128. if (code === 'account_in_freeze')
  129. setUnAvailableEmail(true)
  130. }
  131. }
  132. finally {
  133. setIsCheckingEmail(false)
  134. }
  135. }
  136. const handleNewEmailValueChange = (mailAddress: string) => {
  137. setMail(mailAddress)
  138. setNewEmailExited(false)
  139. if (isValidEmail(mailAddress))
  140. checkNewEmailExisted(mailAddress)
  141. }
  142. const sendCodeToNewEmail = async () => {
  143. if (!isValidEmail(mail)) {
  144. notify({
  145. type: 'error',
  146. message: 'Invalid email format',
  147. })
  148. return
  149. }
  150. await sendEmail(
  151. mail,
  152. false,
  153. stepToken,
  154. )
  155. setStep(STEP.verifyNew)
  156. }
  157. const handleLogout = async () => {
  158. await logout({
  159. url: '/logout',
  160. params: {},
  161. })
  162. localStorage.removeItem('setup_status')
  163. localStorage.removeItem('console_token')
  164. localStorage.removeItem('refresh_token')
  165. router.push('/signin')
  166. }
  167. const updateEmail = async (lastToken: string) => {
  168. try {
  169. await resetEmail({
  170. new_email: mail,
  171. token: lastToken,
  172. })
  173. handleLogout()
  174. }
  175. catch (error) {
  176. notify({
  177. type: 'error',
  178. message: `Error changing email: ${error ? (error as any).message : ''}`,
  179. })
  180. }
  181. }
  182. const submitNewEmail = async () => {
  183. await verifyEmailAddress(mail, code, stepToken, updateEmail)
  184. }
  185. return (
  186. <Modal
  187. isShow={show}
  188. onClose={noop}
  189. className='!w-[420px] !p-6'
  190. >
  191. <div className='absolute right-5 top-5 cursor-pointer p-1.5' onClick={onClose}>
  192. <RiCloseLine className='h-5 w-5 text-text-tertiary' />
  193. </div>
  194. {step === STEP.start && (
  195. <>
  196. <div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.title')}</div>
  197. <div className='space-y-0.5 pb-2 pt-1'>
  198. <div className='body-md-medium text-text-warning'>{t('common.account.changeEmail.authTip')}</div>
  199. <div className='body-md-regular text-text-secondary'>
  200. <Trans
  201. i18nKey="common.account.changeEmail.content1"
  202. components={{ email: <span className='body-md-medium text-text-primary'></span> }}
  203. values={{ email }}
  204. />
  205. </div>
  206. </div>
  207. <div className='pt-3'></div>
  208. <div className='space-y-2'>
  209. <Button
  210. className='!w-full'
  211. variant='primary'
  212. onClick={sendCodeToOriginEmail}
  213. >
  214. {t('common.account.changeEmail.sendVerifyCode')}
  215. </Button>
  216. <Button
  217. className='!w-full'
  218. onClick={onClose}
  219. >
  220. {t('common.operation.cancel')}
  221. </Button>
  222. </div>
  223. </>
  224. )}
  225. {step === STEP.verifyOrigin && (
  226. <>
  227. <div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.verifyEmail')}</div>
  228. <div className='space-y-0.5 pb-2 pt-1'>
  229. <div className='body-md-regular text-text-secondary'>
  230. <Trans
  231. i18nKey="common.account.changeEmail.content2"
  232. components={{ email: <span className='body-md-medium text-text-primary'></span> }}
  233. values={{ email }}
  234. />
  235. </div>
  236. </div>
  237. <div className='pt-3'>
  238. <div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.account.changeEmail.codeLabel')}</div>
  239. <Input
  240. className='!w-full'
  241. placeholder={t('common.account.changeEmail.codePlaceholder')}
  242. value={code}
  243. onChange={e => setCode(e.target.value)}
  244. maxLength={6}
  245. />
  246. </div>
  247. <div className='mt-3 space-y-2'>
  248. <Button
  249. disabled={code.length !== 6}
  250. className='!w-full'
  251. variant='primary'
  252. onClick={handleVerifyOriginEmail}
  253. >
  254. {t('common.account.changeEmail.continue')}
  255. </Button>
  256. <Button
  257. className='!w-full'
  258. onClick={onClose}
  259. >
  260. {t('common.operation.cancel')}
  261. </Button>
  262. </div>
  263. <div className='system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary'>
  264. <span>{t('common.account.changeEmail.resendTip')}</span>
  265. {time > 0 && (
  266. <span>{t('common.account.changeEmail.resendCount', { count: time })}</span>
  267. )}
  268. {!time && (
  269. <span onClick={sendCodeToOriginEmail} className='system-xs-medium cursor-pointer text-text-accent-secondary'>{t('common.account.changeEmail.resend')}</span>
  270. )}
  271. </div>
  272. </>
  273. )}
  274. {step === STEP.newEmail && (
  275. <>
  276. <div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.newEmail')}</div>
  277. <div className='space-y-0.5 pb-2 pt-1'>
  278. <div className='body-md-regular text-text-secondary'>{t('common.account.changeEmail.content3')}</div>
  279. </div>
  280. <div className='pt-3'>
  281. <div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.account.changeEmail.emailLabel')}</div>
  282. <Input
  283. className='!w-full'
  284. placeholder={t('common.account.changeEmail.emailPlaceholder')}
  285. value={mail}
  286. onChange={e => handleNewEmailValueChange(e.target.value)}
  287. destructive={newEmailExited || unAvailableEmail}
  288. />
  289. {newEmailExited && (
  290. <div className='body-xs-regular mt-1 py-0.5 text-text-destructive'>{t('common.account.changeEmail.existingEmail')}</div>
  291. )}
  292. {unAvailableEmail && (
  293. <div className='body-xs-regular mt-1 py-0.5 text-text-destructive'>{t('common.account.changeEmail.unAvailableEmail')}</div>
  294. )}
  295. </div>
  296. <div className='mt-3 space-y-2'>
  297. <Button
  298. disabled={!mail || newEmailExited || unAvailableEmail || isCheckingEmail || !isValidEmail(mail)}
  299. className='!w-full'
  300. variant='primary'
  301. onClick={sendCodeToNewEmail}
  302. >
  303. {t('common.account.changeEmail.sendVerifyCode')}
  304. </Button>
  305. <Button
  306. className='!w-full'
  307. onClick={onClose}
  308. >
  309. {t('common.operation.cancel')}
  310. </Button>
  311. </div>
  312. </>
  313. )}
  314. {step === STEP.verifyNew && (
  315. <>
  316. <div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.verifyNew')}</div>
  317. <div className='space-y-0.5 pb-2 pt-1'>
  318. <div className='body-md-regular text-text-secondary'>
  319. <Trans
  320. i18nKey="common.account.changeEmail.content4"
  321. components={{ email: <span className='body-md-medium text-text-primary'></span> }}
  322. values={{ email: mail }}
  323. />
  324. </div>
  325. </div>
  326. <div className='pt-3'>
  327. <div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.account.changeEmail.codeLabel')}</div>
  328. <Input
  329. className='!w-full'
  330. placeholder={t('common.account.changeEmail.codePlaceholder')}
  331. value={code}
  332. onChange={e => setCode(e.target.value)}
  333. maxLength={6}
  334. />
  335. </div>
  336. <div className='mt-3 space-y-2'>
  337. <Button
  338. disabled={code.length !== 6}
  339. className='!w-full'
  340. variant='primary'
  341. onClick={submitNewEmail}
  342. >
  343. {t('common.account.changeEmail.changeTo', { email: mail })}
  344. </Button>
  345. <Button
  346. className='!w-full'
  347. onClick={onClose}
  348. >
  349. {t('common.operation.cancel')}
  350. </Button>
  351. </div>
  352. <div className='system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary'>
  353. <span>{t('common.account.changeEmail.resendTip')}</span>
  354. {time > 0 && (
  355. <span>{t('common.account.changeEmail.resendCount', { count: time })}</span>
  356. )}
  357. {!time && (
  358. <span onClick={sendCodeToNewEmail} className='system-xs-medium cursor-pointer text-text-accent-secondary'>{t('common.account.changeEmail.resend')}</span>
  359. )}
  360. </div>
  361. </>
  362. )}
  363. </Modal>
  364. )
  365. }
  366. export default EmailChangeModal