// SPDX-License-Identifier: AGPL-3.0-or-later import * as Modal from '@app/features/app/components/dialogs/Modal'; import {EXAMPLE_PERSONAL_EMAIL} from '@app/features/app/config/I18nDisplayConstants'; import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit'; import {VERIFICATION_CODE_DESCRIPTOR, VERIFY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {Button} from '@app/features/ui/button/Button'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import * as ToastCommands from '@app/features/ui/commands/ToastCommands'; import {Form} from '@app/features/ui/components/form/Form'; import {Input} from '@app/features/ui/components/form/FormInput'; import {SteppedCarousel} from '@app/features/ui/stepped_carousel/SteppedCarousel'; import * as UserCommands from '@app/features/user/commands/UserCommands'; import type {User} from '@app/features/user/models/User'; import * as FormUtils from '@app/lib/forms'; import {pushApiErrorModal} from '@app/lib/forms'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; import {observer} from 'mobx-react-lite'; import {useEffect, useMemo, useState} from 'react'; import {useForm} from 'react-hook-form'; const UNABLE_TO_START_EMAIL_CHANGE_DESCRIPTOR = msg({ message: 'Unable to start email change', comment: 'Error message in the email change modal.', }); const INVALID_OR_EXPIRED_CODE_DESCRIPTOR = msg({ message: 'Invalid or expired code', comment: 'Error message in the email change modal.', }); const UNABLE_TO_RESEND_CODE_RIGHT_NOW_DESCRIPTOR = msg({ message: 'Unable to resend code right now', comment: 'Error message in the email change modal.', }); const EMAIL_CHANGED_DESCRIPTOR = msg({ message: 'Email changed', comment: 'Short label in the email change modal. Keep it concise.', }); const CHANGE_YOUR_EMAIL_DESCRIPTOR = msg({ message: 'Change your email', comment: 'Short label in the email change modal. Keep it concise.', }); const NEW_EMAIL_FORM_DESCRIPTOR = msg({ message: 'New email form', comment: 'Short label in the email change modal. Keep it concise.', }); const NEW_EMAIL_DESCRIPTOR = msg({ message: 'New email', comment: 'Short label in the email change modal. Keep it concise.', }); type Stage = 'intro' | 'verifyOriginal' | 'newEmail' | 'verifyNew'; const STAGE_ORDER: ReadonlyArray = ['intro', 'verifyOriginal', 'newEmail', 'verifyNew']; interface NewEmailForm { email: string; } interface EmailChangeModalProps { user: User; } export const EmailChangeModal = observer(({user}: EmailChangeModalProps) => { const {i18n} = useLingui(); const resolveApiError = (error: unknown, fallback: string): string => error && typeof error === 'object' && 'body' in error ? FormUtils.extractErrorMessage(i18n, error) : fallback; const newEmailForm = useForm({defaultValues: {email: ''}}); const [stage, setStage] = useState('intro'); const [ticket, setTicket] = useState(null); const [originalProof, setOriginalProof] = useState(null); const [originalCode, setOriginalCode] = useState(''); const [newCode, setNewCode] = useState(''); const [resendOriginalAt, setResendOriginalAt] = useState(null); const [resendNewAt, setResendNewAt] = useState(null); const [submitting, setSubmitting] = useState(false); const [originalCodeError, setOriginalCodeError] = useState(null); const [newCodeError, setNewCodeError] = useState(null); const isEmailVerified = user.verified === true; const [now, setNow] = useState(Date.now()); useEffect(() => { const id = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(id); }, []); const canResendOriginal = useMemo( () => !resendOriginalAt || resendOriginalAt.getTime() <= now, [resendOriginalAt, now], ); const canResendNew = useMemo(() => !resendNewAt || resendNewAt.getTime() <= now, [resendNewAt, now]); const originalSecondsRemaining = useMemo( () => (resendOriginalAt ? Math.max(0, Math.ceil((resendOriginalAt.getTime() - now) / 1000)) : 0), [resendOriginalAt, now], ); const newSecondsRemaining = useMemo( () => (resendNewAt ? Math.max(0, Math.ceil((resendNewAt.getTime() - now) / 1000)) : 0), [resendNewAt, now], ); const startFlow = async () => { setSubmitting(true); setOriginalCodeError(null); try { const result = await UserCommands.startEmailChange(); setTicket(result.ticket); if (result.original_proof) { setOriginalProof(result.original_proof); } if (result.resend_available_at) { setResendOriginalAt(new Date(result.resend_available_at)); } setStage(result.require_original ? 'verifyOriginal' : 'newEmail'); } catch (error: unknown) { pushApiErrorModal(i18n, error, i18n._(UNABLE_TO_START_EMAIL_CHANGE_DESCRIPTOR)); } finally { setSubmitting(false); } }; const handleVerifyOriginal = async () => { if (!ticket) return; setSubmitting(true); setOriginalCodeError(null); try { const result = await UserCommands.verifyEmailChangeOriginal(ticket, originalCode); setOriginalProof(result.original_proof); setStage('newEmail'); } catch (error: unknown) { setOriginalCodeError(resolveApiError(error, i18n._(INVALID_OR_EXPIRED_CODE_DESCRIPTOR))); } finally { setSubmitting(false); } }; const handleResendOriginal = async () => { if (!ticket || !canResendOriginal) return; setSubmitting(true); try { await UserCommands.resendEmailChangeOriginal(ticket); setResendOriginalAt(new Date(Date.now() + 30 * 1000)); } catch (error: unknown) { pushApiErrorModal(i18n, error, i18n._(UNABLE_TO_RESEND_CODE_RIGHT_NOW_DESCRIPTOR)); } finally { setSubmitting(false); } }; const handleRequestNew = async (data: NewEmailForm) => { if (!ticket || !originalProof) return; setSubmitting(true); try { const result = await UserCommands.requestEmailChangeNew(ticket, data.email, originalProof); setResendNewAt(result.resend_available_at ? new Date(result.resend_available_at) : null); setStage('verifyNew'); } finally { setSubmitting(false); } }; const handleResendNew = async () => { if (!ticket || !canResendNew) return; setSubmitting(true); try { await UserCommands.resendEmailChangeNew(ticket); setResendNewAt(new Date(Date.now() + 30 * 1000)); } catch (error: unknown) { pushApiErrorModal(i18n, error, i18n._(UNABLE_TO_RESEND_CODE_RIGHT_NOW_DESCRIPTOR)); } finally { setSubmitting(false); } }; const handleVerifyNew = async () => { if (!ticket || !originalProof) return; setSubmitting(true); setNewCodeError(null); try { const result = await UserCommands.verifyEmailChangeNew(ticket, newCode, originalProof); await UserCommands.update({email_token: result.email_token}); ToastCommands.createToast({type: 'success', children: i18n._(EMAIL_CHANGED_DESCRIPTOR)}); ModalCommands.pop(); } catch (error: unknown) { setNewCodeError(resolveApiError(error, i18n._(INVALID_OR_EXPIRED_CODE_DESCRIPTOR))); } finally { setSubmitting(false); } }; const {handleSubmit: handleNewEmailSubmit} = useFormSubmit({ form: newEmailForm, onSubmit: handleRequestNew, defaultErrorField: 'email', }); const renderIntroStage = () => ( {isEmailVerified ? ( We'll verify your current email and then your new email with one-time codes. ) : ( We'll verify your new email with a one-time code. )} ); const renderVerifyOriginalStage = () => ( <> Enter the code sent to your current email. ) => setOriginalCode(event.target.value)} label={i18n._(VERIFICATION_CODE_DESCRIPTOR)} placeholder="XXXX-XXXX" required={true} error={originalCodeError ?? undefined} data-flx="user.email-change-modal.input.set-original-code" /> ); const renderNewEmailStage = () => (
Enter the new email you want to use. We'll send a code there next.
); const renderVerifyNewStage = () => ( <> Enter the code we emailed to your new address. ) => setNewCode(event.target.value)} label={i18n._(VERIFICATION_CODE_DESCRIPTOR)} placeholder="XXXX-XXXX" required={true} error={newCodeError ?? undefined} data-flx="user.email-change-modal.input.set-new-code" /> ); const renderStageBody = () => { switch (stage) { case 'intro': return renderIntroStage(); case 'verifyOriginal': return renderVerifyOriginalStage(); case 'newEmail': return renderNewEmailStage(); case 'verifyNew': return renderVerifyNewStage(); } }; const renderStageFooter = () => { switch (stage) { case 'intro': return ( <> ); case 'verifyOriginal': return ( <> ); case 'newEmail': return ( <> ); case 'verifyNew': return ( <> ); } }; return ( {renderStageBody()} {renderStageFooter()} ); });