feat(connected-accounts): email-merge confirmation via emailed one-time code

When linking an email that belongs to another account, the backend now mails a
one-time code to it (merge_verification='email_code') instead of asking for that
account's password. Add a 6-digit code step: on the email_code response show the
code input, verify via authApi.verifyEmailMerge → on success redirect to /merge.
Preview/execute already go through apiClient (JWT), so they satisfy the new
initiator-bound merge endpoints. i18n in ru/en/zh/fa.
This commit is contained in:
c0mrade
2026-06-04 19:06:54 +03:00
parent c6fc167d80
commit 3224320095
6 changed files with 186 additions and 63 deletions

View File

@@ -87,7 +87,10 @@ export const authApi = {
message: string; message: string;
email?: string; email?: string;
merge_required?: boolean; merge_required?: boolean;
merge_token?: string; // 'email_code' means the email belongs to another account: a confirmation
// code was mailed to it and must be verified before a merge token is issued.
merge_verification?: 'email_code';
merge_token?: string | null;
}> => { }> => {
const response = await apiClient.post('/cabinet/auth/email/register', { const response = await apiClient.post('/cabinet/auth/email/register', {
email, email,
@@ -97,6 +100,14 @@ export const authApi = {
return response.data; return response.data;
}, },
// Confirm an email account merge with the code sent to the existing account.
verifyEmailMerge: async (
code: string,
): Promise<{ message: string; merge_required?: boolean; merge_token?: string }> => {
const response = await apiClient.post('/cabinet/auth/email/merge/verify', { code });
return response.data;
},
registerEmailStandalone: async (data: { registerEmailStandalone: async (data: {
email: string; email: string;
password: string; password: string;

View File

@@ -4595,7 +4595,12 @@
}, },
"backToAccounts": "Back to accounts" "backToAccounts": "Back to accounts"
}, },
"emailMergePasswordRequired": "This email already belongs to another account. To merge the accounts, enter that account's password." "emailMergePasswordRequired": "This email already belongs to another account. To merge the accounts, enter that account's password.",
"emailMergeCodeSent": "A confirmation code was sent to that email.",
"emailMergeCodeDescription": "This email already belongs to another account. We sent a code to it — enter it to confirm ownership and merge the accounts.",
"emailMergeCodeLabel": "Code from the email",
"emailMergeConfirm": "Confirm and merge",
"emailMergeCodeInvalid": "Invalid or expired code"
}, },
"theme": { "theme": {
"colors": "Theme Colors", "colors": "Theme Colors",

View File

@@ -4140,7 +4140,12 @@
}, },
"backToAccounts": "بازگشت به فهرست حساب‌ها" "backToAccounts": "بازگشت به فهرست حساب‌ها"
}, },
"emailMergePasswordRequired": "این ایمیل قبلاً به حساب دیگری تعلق دارد. برای ادغام حساب‌ها، رمز عبور آن حساب را وارد کنید." "emailMergePasswordRequired": "این ایمیل قبلاً به حساب دیگری تعلق دارد. برای ادغام حساب‌ها، رمز عبور آن حساب را وارد کنید.",
"emailMergeCodeSent": "کد تأیید به آن ایمیل ارسال شد.",
"emailMergeCodeDescription": "این ایمیل قبلاً به حساب دیگری تعلق دارد. کدی به آن ارسال کردیم — برای تأیید مالکیت و ادغام حساب‌ها آن را وارد کنید.",
"emailMergeCodeLabel": "کد ارسال‌شده در ایمیل",
"emailMergeConfirm": "تأیید و ادغام",
"emailMergeCodeInvalid": "کد نامعتبر یا منقضی‌شده"
}, },
"theme": { "theme": {
"colors": "رنگ‌های تم", "colors": "رنگ‌های تم",

View File

@@ -5147,7 +5147,12 @@
}, },
"backToAccounts": "К списку аккаунтов" "backToAccounts": "К списку аккаунтов"
}, },
"emailMergePasswordRequired": "Этот email уже привязан к другому аккаунту. Чтобы объединить аккаунты, введите пароль от него." "emailMergePasswordRequired": "Этот email уже привязан к другому аккаунту. Чтобы объединить аккаунты, введите пароль от него.",
"emailMergeCodeSent": "Код подтверждения отправлен на тот email.",
"emailMergeCodeDescription": "Этот email уже привязан к другому аккаунту. Мы отправили на него код — введите его, чтобы подтвердить владение и объединить аккаунты.",
"emailMergeCodeLabel": "Код из письма",
"emailMergeConfirm": "Подтвердить и объединить",
"emailMergeCodeInvalid": "Неверный или просроченный код"
}, },
"theme": { "theme": {
"colors": "Цвета темы", "colors": "Цвета темы",

View File

@@ -4139,7 +4139,12 @@
}, },
"backToAccounts": "返回账户列表" "backToAccounts": "返回账户列表"
}, },
"emailMergePasswordRequired": "该邮箱已属于另一个账户。要合并账户,请输入该账户的密码。" "emailMergePasswordRequired": "该邮箱已属于另一个账户。要合并账户,请输入该账户的密码。",
"emailMergeCodeSent": "确认码已发送至该邮箱。",
"emailMergeCodeDescription": "该邮箱已属于另一个账户。我们已向其发送验证码——请输入以确认所有权并合并账户。",
"emailMergeCodeLabel": "邮件中的验证码",
"emailMergeConfirm": "确认并合并",
"emailMergeCodeInvalid": "验证码无效或已过期"
}, },
"theme": { "theme": {
"colors": "主题颜色", "colors": "主题颜色",

View File

@@ -318,6 +318,10 @@ export default function ConnectedAccounts() {
const [emailConfirmPassword, setEmailConfirmPassword] = useState(''); const [emailConfirmPassword, setEmailConfirmPassword] = useState('');
const [emailError, setEmailError] = useState<string | null>(null); const [emailError, setEmailError] = useState<string | null>(null);
const [emailSuccess, setEmailSuccess] = useState<string | null>(null); const [emailSuccess, setEmailSuccess] = useState<string | null>(null);
// Email-merge confirmation: the target email belongs to another account, so a
// one-time code was mailed to it; verifying the code yields the merge token.
const [emailMergeCodePending, setEmailMergeCodePending] = useState(false);
const [emailMergeCode, setEmailMergeCode] = useState('');
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
const { data: emailAuthConfig } = useQuery<EmailAuthEnabled>({ const { data: emailAuthConfig } = useQuery<EmailAuthEnabled>({
@@ -396,6 +400,15 @@ export default function ConnectedAccounts() {
navigate(`/merge/${response.merge_token}`, { replace: true }); navigate(`/merge/${response.merge_token}`, { replace: true });
return; return;
} }
// The email belongs to another account: a one-time code was mailed to it.
// Switch to the code step; verifying it yields the merge token.
if (response.merge_required && response.merge_verification === 'email_code') {
setEmailMergeCodePending(true);
setEmailMergeCode('');
setEmailSuccess(t('profile.emailMergeCodeSent'));
setEmailError(null);
return;
}
setEmailSuccess(t('profile.emailSent')); setEmailSuccess(t('profile.emailSent'));
setEmailError(null); setEmailError(null);
setEmailValue(''); setEmailValue('');
@@ -425,6 +438,36 @@ export default function ConnectedAccounts() {
}, },
}); });
const verifyEmailMergeMutation = useMutation({
mutationFn: (code: string) => authApi.verifyEmailMerge(code),
onSuccess: (response) => {
if (response.merge_token) {
navigate(`/merge/${response.merge_token}`, { replace: true });
}
},
onError: (err: { response?: { data?: { detail?: string } } }) => {
setEmailError(err.response?.data?.detail || t('profile.emailMergeCodeInvalid'));
},
});
const handleVerifyMergeCode = (e: React.SyntheticEvent) => {
e.preventDefault();
setEmailError(null);
const code = emailMergeCode.trim();
if (!/^\d{6}$/.test(code)) {
setEmailError(t('profile.emailMergeCodeInvalid'));
return;
}
verifyEmailMergeMutation.mutate(code);
};
const cancelEmailMerge = () => {
setEmailMergeCodePending(false);
setEmailMergeCode('');
setEmailError(null);
setEmailSuccess(null);
};
const handleEmailSubmit = (e: React.SyntheticEvent) => { const handleEmailSubmit = (e: React.SyntheticEvent) => {
e.preventDefault(); e.preventDefault();
setEmailError(null); setEmailError(null);
@@ -685,8 +728,54 @@ export default function ConnectedAccounts() {
> >
<div className="mt-4 border-t border-dark-700/30 pt-4"> <div className="mt-4 border-t border-dark-700/30 pt-4">
<p className="mb-4 text-sm text-dark-400"> <p className="mb-4 text-sm text-dark-400">
{t('profile.linkEmailDescription')} {emailMergeCodePending
? t('profile.emailMergeCodeDescription')
: t('profile.linkEmailDescription')}
</p> </p>
{emailMergeCodePending ? (
<form onSubmit={handleVerifyMergeCode} className="space-y-3">
<div>
<label htmlFor="email-merge-code" className="label">
{t('profile.emailMergeCodeLabel')}
</label>
<input
id="email-merge-code"
type="text"
inputMode="numeric"
maxLength={6}
value={emailMergeCode}
onChange={(e) => setEmailMergeCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
className="input tracking-[0.5em]"
autoComplete="one-time-code"
/>
</div>
{emailError && (
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
{emailError}
</div>
)}
{emailSuccess && (
<div className="rounded-xl border border-success-500/30 bg-success-500/10 p-3 text-sm text-success-400">
{emailSuccess}
</div>
)}
<Button
type="submit"
fullWidth
loading={verifyEmailMergeMutation.isPending}
>
{t('profile.emailMergeConfirm')}
</Button>
<button
type="button"
onClick={cancelEmailMerge}
className="w-full text-sm text-dark-400 transition-colors hover:text-dark-200"
>
{t('common.cancel')}
</button>
</form>
) : (
<form onSubmit={handleEmailSubmit} className="space-y-3"> <form onSubmit={handleEmailSubmit} className="space-y-3">
<div> <div>
<label htmlFor="email-link-input" className="label"> <label htmlFor="email-link-input" className="label">
@@ -715,7 +804,9 @@ export default function ConnectedAccounts() {
className="input" className="input"
autoComplete="new-password" autoComplete="new-password"
/> />
<p className="mt-1 text-xs text-dark-500">{t('profile.passwordHint')}</p> <p className="mt-1 text-xs text-dark-500">
{t('profile.passwordHint')}
</p>
</div> </div>
<div> <div>
<label htmlFor="email-link-confirm" className="label"> <label htmlFor="email-link-confirm" className="label">
@@ -747,6 +838,7 @@ export default function ConnectedAccounts() {
{t('profile.linkEmail')} {t('profile.linkEmail')}
</Button> </Button>
</form> </form>
)}
</div> </div>
</motion.div> </motion.div>
)} )}