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;
|
||||
email?: string;
|
||||
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', {
|
||||
email,
|
||||
@@ -97,6 +100,14 @@ export const authApi = {
|
||||
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: {
|
||||
email: string;
|
||||
password: string;
|
||||
|
||||
@@ -4595,7 +4595,12 @@
|
||||
},
|
||||
"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": {
|
||||
"colors": "Theme Colors",
|
||||
|
||||
@@ -4140,7 +4140,12 @@
|
||||
},
|
||||
"backToAccounts": "بازگشت به فهرست حسابها"
|
||||
},
|
||||
"emailMergePasswordRequired": "این ایمیل قبلاً به حساب دیگری تعلق دارد. برای ادغام حسابها، رمز عبور آن حساب را وارد کنید."
|
||||
"emailMergePasswordRequired": "این ایمیل قبلاً به حساب دیگری تعلق دارد. برای ادغام حسابها، رمز عبور آن حساب را وارد کنید.",
|
||||
"emailMergeCodeSent": "کد تأیید به آن ایمیل ارسال شد.",
|
||||
"emailMergeCodeDescription": "این ایمیل قبلاً به حساب دیگری تعلق دارد. کدی به آن ارسال کردیم — برای تأیید مالکیت و ادغام حسابها آن را وارد کنید.",
|
||||
"emailMergeCodeLabel": "کد ارسالشده در ایمیل",
|
||||
"emailMergeConfirm": "تأیید و ادغام",
|
||||
"emailMergeCodeInvalid": "کد نامعتبر یا منقضیشده"
|
||||
},
|
||||
"theme": {
|
||||
"colors": "رنگهای تم",
|
||||
|
||||
@@ -5147,7 +5147,12 @@
|
||||
},
|
||||
"backToAccounts": "К списку аккаунтов"
|
||||
},
|
||||
"emailMergePasswordRequired": "Этот email уже привязан к другому аккаунту. Чтобы объединить аккаунты, введите пароль от него."
|
||||
"emailMergePasswordRequired": "Этот email уже привязан к другому аккаунту. Чтобы объединить аккаунты, введите пароль от него.",
|
||||
"emailMergeCodeSent": "Код подтверждения отправлен на тот email.",
|
||||
"emailMergeCodeDescription": "Этот email уже привязан к другому аккаунту. Мы отправили на него код — введите его, чтобы подтвердить владение и объединить аккаунты.",
|
||||
"emailMergeCodeLabel": "Код из письма",
|
||||
"emailMergeConfirm": "Подтвердить и объединить",
|
||||
"emailMergeCodeInvalid": "Неверный или просроченный код"
|
||||
},
|
||||
"theme": {
|
||||
"colors": "Цвета темы",
|
||||
|
||||
@@ -4139,7 +4139,12 @@
|
||||
},
|
||||
"backToAccounts": "返回账户列表"
|
||||
},
|
||||
"emailMergePasswordRequired": "该邮箱已属于另一个账户。要合并账户,请输入该账户的密码。"
|
||||
"emailMergePasswordRequired": "该邮箱已属于另一个账户。要合并账户,请输入该账户的密码。",
|
||||
"emailMergeCodeSent": "确认码已发送至该邮箱。",
|
||||
"emailMergeCodeDescription": "该邮箱已属于另一个账户。我们已向其发送验证码——请输入以确认所有权并合并账户。",
|
||||
"emailMergeCodeLabel": "邮件中的验证码",
|
||||
"emailMergeConfirm": "确认并合并",
|
||||
"emailMergeCodeInvalid": "验证码无效或已过期"
|
||||
},
|
||||
"theme": {
|
||||
"colors": "主题颜色",
|
||||
|
||||
@@ -318,6 +318,10 @@ export default function ConnectedAccounts() {
|
||||
const [emailConfirmPassword, setEmailConfirmPassword] = useState('');
|
||||
const [emailError, setEmailError] = 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 { data: emailAuthConfig } = useQuery<EmailAuthEnabled>({
|
||||
@@ -396,6 +400,15 @@ export default function ConnectedAccounts() {
|
||||
navigate(`/merge/${response.merge_token}`, { replace: true });
|
||||
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'));
|
||||
setEmailError(null);
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
setEmailError(null);
|
||||
@@ -685,68 +728,117 @@ export default function ConnectedAccounts() {
|
||||
>
|
||||
<div className="mt-4 border-t border-dark-700/30 pt-4">
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t('profile.linkEmailDescription')}
|
||||
{emailMergeCodePending
|
||||
? t('profile.emailMergeCodeDescription')
|
||||
: t('profile.linkEmailDescription')}
|
||||
</p>
|
||||
<form onSubmit={handleEmailSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="email-link-input" className="label">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email-link-input"
|
||||
type="email"
|
||||
value={emailValue}
|
||||
onChange={(e) => setEmailValue(e.target.value)}
|
||||
placeholder="email@example.com"
|
||||
className="input"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email-link-password" className="label">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<input
|
||||
id="email-link-password"
|
||||
type="password"
|
||||
value={emailPassword}
|
||||
onChange={(e) => setEmailPassword(e.target.value)}
|
||||
placeholder={t('profile.passwordPlaceholder')}
|
||||
className="input"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">{t('profile.passwordHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email-link-confirm" className="label">
|
||||
{t('auth.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
id="email-link-confirm"
|
||||
type="password"
|
||||
value={emailConfirmPassword}
|
||||
onChange={(e) => setEmailConfirmPassword(e.target.value)}
|
||||
placeholder={t('profile.confirmPasswordPlaceholder')}
|
||||
className="input"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{emailError && (
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{emailError}
|
||||
{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>
|
||||
)}
|
||||
{emailSuccess && (
|
||||
<div className="rounded-xl border border-success-500/30 bg-success-500/10 p-3 text-sm text-success-400">
|
||||
{emailSuccess}
|
||||
{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">
|
||||
<div>
|
||||
<label htmlFor="email-link-input" className="label">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email-link-input"
|
||||
type="email"
|
||||
value={emailValue}
|
||||
onChange={(e) => setEmailValue(e.target.value)}
|
||||
placeholder="email@example.com"
|
||||
className="input"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email-link-password" className="label">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<input
|
||||
id="email-link-password"
|
||||
type="password"
|
||||
value={emailPassword}
|
||||
onChange={(e) => setEmailPassword(e.target.value)}
|
||||
placeholder={t('profile.passwordPlaceholder')}
|
||||
className="input"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('profile.passwordHint')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email-link-confirm" className="label">
|
||||
{t('auth.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
id="email-link-confirm"
|
||||
type="password"
|
||||
value={emailConfirmPassword}
|
||||
onChange={(e) => setEmailConfirmPassword(e.target.value)}
|
||||
placeholder={t('profile.confirmPasswordPlaceholder')}
|
||||
className="input"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" fullWidth loading={registerEmailMutation.isPending}>
|
||||
{t('profile.linkEmail')}
|
||||
</Button>
|
||||
</form>
|
||||
{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={registerEmailMutation.isPending}>
|
||||
{t('profile.linkEmail')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user