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:
@@ -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