mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -4140,7 +4140,12 @@
|
|||||||
},
|
},
|
||||||
"backToAccounts": "بازگشت به فهرست حسابها"
|
"backToAccounts": "بازگشت به فهرست حسابها"
|
||||||
},
|
},
|
||||||
"emailMergePasswordRequired": "این ایمیل قبلاً به حساب دیگری تعلق دارد. برای ادغام حسابها، رمز عبور آن حساب را وارد کنید."
|
"emailMergePasswordRequired": "این ایمیل قبلاً به حساب دیگری تعلق دارد. برای ادغام حسابها، رمز عبور آن حساب را وارد کنید.",
|
||||||
|
"emailMergeCodeSent": "کد تأیید به آن ایمیل ارسال شد.",
|
||||||
|
"emailMergeCodeDescription": "این ایمیل قبلاً به حساب دیگری تعلق دارد. کدی به آن ارسال کردیم — برای تأیید مالکیت و ادغام حسابها آن را وارد کنید.",
|
||||||
|
"emailMergeCodeLabel": "کد ارسالشده در ایمیل",
|
||||||
|
"emailMergeConfirm": "تأیید و ادغام",
|
||||||
|
"emailMergeCodeInvalid": "کد نامعتبر یا منقضیشده"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"colors": "رنگهای تم",
|
"colors": "رنگهای تم",
|
||||||
|
|||||||
@@ -5147,7 +5147,12 @@
|
|||||||
},
|
},
|
||||||
"backToAccounts": "К списку аккаунтов"
|
"backToAccounts": "К списку аккаунтов"
|
||||||
},
|
},
|
||||||
"emailMergePasswordRequired": "Этот email уже привязан к другому аккаунту. Чтобы объединить аккаунты, введите пароль от него."
|
"emailMergePasswordRequired": "Этот email уже привязан к другому аккаунту. Чтобы объединить аккаунты, введите пароль от него.",
|
||||||
|
"emailMergeCodeSent": "Код подтверждения отправлен на тот email.",
|
||||||
|
"emailMergeCodeDescription": "Этот email уже привязан к другому аккаунту. Мы отправили на него код — введите его, чтобы подтвердить владение и объединить аккаунты.",
|
||||||
|
"emailMergeCodeLabel": "Код из письма",
|
||||||
|
"emailMergeConfirm": "Подтвердить и объединить",
|
||||||
|
"emailMergeCodeInvalid": "Неверный или просроченный код"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"colors": "Цвета темы",
|
"colors": "Цвета темы",
|
||||||
|
|||||||
@@ -4139,7 +4139,12 @@
|
|||||||
},
|
},
|
||||||
"backToAccounts": "返回账户列表"
|
"backToAccounts": "返回账户列表"
|
||||||
},
|
},
|
||||||
"emailMergePasswordRequired": "该邮箱已属于另一个账户。要合并账户,请输入该账户的密码。"
|
"emailMergePasswordRequired": "该邮箱已属于另一个账户。要合并账户,请输入该账户的密码。",
|
||||||
|
"emailMergeCodeSent": "确认码已发送至该邮箱。",
|
||||||
|
"emailMergeCodeDescription": "该邮箱已属于另一个账户。我们已向其发送验证码——请输入以确认所有权并合并账户。",
|
||||||
|
"emailMergeCodeLabel": "邮件中的验证码",
|
||||||
|
"emailMergeConfirm": "确认并合并",
|
||||||
|
"emailMergeCodeInvalid": "验证码无效或已过期"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"colors": "主题颜色",
|
"colors": "主题颜色",
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user