// 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 RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import { CLAIM_ACCOUNT_DESCRIPTOR, EMAIL_DESCRIPTOR, PASSWORD_DESCRIPTOR, USERNAME_DESCRIPTOR, VERIFICATION_CODE_DESCRIPTOR, } from '@app/features/i18n/utils/CommonMessageDescriptors'; import type {HttpError} from '@app/features/platform/types/EndpointError'; import {Button} from '@app/features/ui/button/Button'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} 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 ModalState from '@app/features/ui/state/Modal'; import {SteppedCarousel} from '@app/features/ui/stepped_carousel/SteppedCarousel'; import {createRecoveryKitWithPasswordAndOpen} from '@app/features/user/commands/RecoveryKitCommands'; import * as UserCommands from '@app/features/user/commands/UserCommands'; import Users from '@app/features/user/state/Users'; import * as FormUtils 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 VERIFICATION_CODE_SENT_DESCRIPTOR = msg({ message: 'Verification code sent', comment: 'Short label in the authentication claim account modal. Keep the tone plain and specific.', }); const ACCOUNT_CLAIMED_SUCCESSFULLY_DESCRIPTOR = msg({ message: 'Account claimed successfully', comment: 'Short label in the authentication claim account modal. Keep the tone plain and specific.', }); const CODE_RESENT_DESCRIPTOR = msg({ message: 'Code resent', comment: 'Short label in the authentication claim account modal. Keep the tone plain and specific.', }); const CLAIM_YOUR_ACCOUNT_DESCRIPTOR = msg({ message: 'Claim your account', comment: 'Short label in the authentication claim account modal. Keep the tone plain and specific.', }); const CONFIRM_PASSWORD_DESCRIPTOR = msg({ message: 'Confirm password', comment: 'Label for the password confirmation field in the claim account modal.', }); const PASSWORDS_DO_NOT_MATCH_DESCRIPTOR = msg({ message: 'Passwords do not match', comment: 'Claim account modal error when the password and its confirmation differ.', }); interface FormInputs { email: string; newPassword: string; verificationCode: string; } type Stage = 'collect' | 'verify'; const STAGE_ORDER: ReadonlyArray = ['collect', 'verify']; const CLAIM_ACCOUNT_MODAL_KEY = 'claim-account-modal'; interface UsernameClaimInputs { username: string; newPassword: string; confirmPassword: string; } const USERNAME_CLAIM_PATH_MAP = {new_password: 'newPassword'} as const; const UsernameClaimAccountModal = observer(() => { const {i18n} = useLingui(); const form = useForm({ defaultValues: {username: Users.currentUser?.username ?? '', newPassword: '', confirmPassword: ''}, }); const handleClaim = async (data: UsernameClaimInputs) => { if (data.newPassword !== data.confirmPassword) { form.setError('confirmPassword', {type: 'validate', message: i18n._(PASSWORDS_DO_NOT_MATCH_DESCRIPTOR)}); return; } const updated = await UserCommands.update({username: data.username.trim(), new_password: data.newPassword}); ModalCommands.popWithKey(CLAIM_ACCOUNT_MODAL_KEY); ToastCommands.createToast({type: 'success', children: i18n._(ACCOUNT_CLAIMED_SUCCESSFULLY_DESCRIPTOR)}); void createRecoveryKitWithPasswordAndOpen({ userId: updated.id, password: data.newPassword, username: updated.username, discriminator: updated.discriminator, }); }; const {handleSubmit, isSubmitting} = useFormSubmit({ form, onSubmit: handleClaim, defaultErrorField: 'username', pathMap: USERNAME_CLAIM_PATH_MAP, }); return (
Claim your account by choosing a username and password. You sign in with them, so pick ones you will remember.
); }); const EmailClaimAccountModal = observer(() => { const {i18n} = useLingui(); const form = useForm({ defaultValues: {email: '', newPassword: '', verificationCode: ''}, }); const [stage, setStage] = useState('collect'); const [ticket, setTicket] = useState(null); const [originalProof, setOriginalProof] = useState(null); const [emailToken, setEmailToken] = useState(null); const [resendNewAt, setResendNewAt] = useState(null); const [submittingAction, setSubmittingAction] = useState(false); const [now, setNow] = useState(Date.now()); useEffect(() => { const id = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(id); }, []); const canResendNew = useMemo(() => !resendNewAt || resendNewAt.getTime() <= now, [resendNewAt, now]); const resendSecondsRemaining = useMemo(() => { if (!resendNewAt) return 0; return Math.max(0, Math.ceil((resendNewAt.getTime() - now) / 1000)); }, [resendNewAt, now]); const startEmailTokenFlow = async (data: FormInputs) => { let activeTicket = ticket; let activeProof = originalProof; if (!activeTicket || !activeProof) { const startResult = await UserCommands.startEmailChange(); activeTicket = startResult.ticket; activeProof = startResult.original_proof ?? null; setTicket(startResult.ticket); setOriginalProof(activeProof); if (startResult.resend_available_at) { setResendNewAt(new Date(startResult.resend_available_at)); } } if (!activeProof) { throw new Error('Missing original proof token'); } const result = await UserCommands.requestEmailChangeNew(activeTicket!, data.email, activeProof, data.newPassword); setResendNewAt(result.resend_available_at ? new Date(result.resend_available_at) : null); form.setValue('verificationCode', ''); form.clearErrors('verificationCode'); setStage('verify'); ToastCommands.createToast({type: 'success', children: i18n._(VERIFICATION_CODE_SENT_DESCRIPTOR)}); }; const handleVerifyNew = async (data: FormInputs) => { if (!ticket || !originalProof) return; setSubmittingAction(true); try { let activeEmailToken = emailToken; if (!activeEmailToken) { const verified = await UserCommands.verifyEmailChangeNew(ticket, data.verificationCode, originalProof); activeEmailToken = verified.email_token; setEmailToken(activeEmailToken); } await UserCommands.update({ email_token: activeEmailToken, new_password: data.newPassword, }); ToastCommands.createToast({type: 'success', children: i18n._(ACCOUNT_CLAIMED_SUCCESSFULLY_DESCRIPTOR)}); ModalCommands.pop(); } catch (error: unknown) { FormUtils.handleError(i18n, form, error as HttpError, 'verificationCode', { pathMap: {new_password: 'newPassword'}, }); } finally { setSubmittingAction(false); } }; const handleResendNew = async () => { if (!ticket || !canResendNew) return; setSubmittingAction(true); try { await UserCommands.resendEmailChangeNew(ticket); setResendNewAt(new Date(Date.now() + 30 * 1000)); ToastCommands.createToast({type: 'success', children: i18n._(CODE_RESENT_DESCRIPTOR)}); } catch (error: unknown) { FormUtils.handleError(i18n, form, error as HttpError, 'verificationCode'); } finally { setSubmittingAction(false); } }; const {handleSubmit, isSubmitting} = useFormSubmit({ form, onSubmit: stage === 'collect' ? startEmailTokenFlow : handleVerifyNew, defaultErrorField: stage === 'collect' ? 'email' : 'verificationCode', }); const renderCollectStage = () => ( <> Claim your account by adding an email and password. We will send a verification code to confirm your email before finishing. ); const renderVerifyStage = () => ( <> Enter the code we sent to your email to verify it. Your password will be set once the code is confirmed. ); const renderStageBody = () => (stage === 'collect' ? renderCollectStage() : renderVerifyStage()); const renderStageFooter = () => { if (stage === 'collect') { return ( <> ); } return ( <> ); }; return (
{renderStageBody()} {renderStageFooter()}
); }); const ClaimAccountModal = observer(() => RuntimeConfig.usesUsernameSignIn ? ( ) : ( ), ); let hasShownClaimAccountModalThisSession = false; export const openClaimAccountModal = ({force = false}: {force?: boolean} = {}): void => { if (ModalState.hasModal(CLAIM_ACCOUNT_MODAL_KEY)) { return; } if (!force && hasShownClaimAccountModalThisSession) { return; } hasShownClaimAccountModalThisSession = true; ModalCommands.pushWithKey( modal(() => ), CLAIM_ACCOUNT_MODAL_KEY, ); };