feat(auth): handle google oauth account claim conflicts
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-14 21:15:30 +03:30
parent 38ba89b82f
commit cd5409c9b2
4 changed files with 105 additions and 45 deletions

View File

@@ -119,11 +119,16 @@ export type GoogleOAuthFlowResponse =
first_name: string; first_name: string;
last_name: string; last_name: string;
avatar_url: string; avatar_url: string;
resolution: "new_account" | "existing_email_claim";
mobile_hint?: string | null;
} }
| { | {
status: "claim_required"; status: "claim_required";
mobile: string; mobile: string;
detail?: string; detail?: string;
email: string;
resolution: "existing_email_claim" | "existing_mobile_claim";
mobile_hint?: string | null;
}; };
export const getGoogleOAuthFlow = async (flow: string): Promise<GoogleOAuthFlowResponse> => { export const getGoogleOAuthFlow = async (flow: string): Promise<GoogleOAuthFlowResponse> => {

View File

@@ -109,26 +109,31 @@ export const en = {
countdownLabel: (time: string) => `Retry in ${time}`, countdownLabel: (time: string) => `Retry in ${time}`,
fallback: "Too many requests. Please wait and try again.", fallback: "Too many requests. Please wait and try again.",
}, },
google: { google: {
loadingTitle: "Completing Google sign in", loadingTitle: "Completing Google sign in",
loadingDescription: "We are validating your Google account and preparing the next step.", loadingDescription: "We are validating your Google account and preparing the next step.",
collectMobileTitle: "Finish your account setup", collectMobileTitle: "Finish your account setup",
collectMobileDescription: (email: string) => collectMobileDescription: (email: string) =>
`Google verified ${email}. Enter your mobile number to finish creating your account.`, `Google verified ${email}. Enter your mobile number to finish creating your account.`,
claimTitle: "Verify your existing account", existingEmailClaimDescription: (email: string, mobileHint: string) =>
claimDescription: (mobile: string) => `Google verified ${email}. Enter the mobile number already connected to this account (${mobileHint}) to confirm ownership.`,
`An account with ${mobile} already exists. Enter the verification code sent to that number to attach Google.`, claimTitle: "Verify your existing account",
errorTitle: "Google sign in could not be completed", claimDescription: (mobile: string) =>
missingFlow: "The Google sign-in flow is missing or has expired.", `An account with ${mobile} already exists. Enter the verification code sent to that number to attach Google.`,
loadFailed: "We could not load your Google sign-in state.", mobileClaimDescription: (mobile: string) =>
completeFailed: "We could not finish your Google account setup.", `A mobile-only account with ${mobile} already exists. Verify the code sent to that number to attach Google and keep that account.`,
claimOtpSent: "Verification code sent successfully.", errorTitle: "Google sign in could not be completed",
googleAccount: "Google account", missingFlow: "The Google sign-in flow is missing or has expired.",
completeButton: "Continue and create account", loadFailed: "We could not load your Google sign-in state.",
verifyClaimButton: "Verify and continue", completeFailed: "We could not finish your Google account setup.",
resendClaimOtp: "Resend verification code", claimOtpSent: "Verification code sent successfully.",
restartGoogle: "Start Google sign in again", googleAccount: "Google account",
}, mobileHintLabel: (mobileHint: string) => `Expected mobile: ${mobileHint}`,
completeButton: "Continue and create account",
verifyClaimButton: "Verify and continue",
resendClaimOtp: "Resend verification code",
restartGoogle: "Start Google sign in again",
},
}, },
loginTerms: { loginTerms: {

View File

@@ -109,25 +109,30 @@ export const fa = {
countdownLabel: (time: string) => `تلاش دوباره تا ${time}`, countdownLabel: (time: string) => `تلاش دوباره تا ${time}`,
fallback: "درخواست‌های زیادی ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.", fallback: "درخواست‌های زیادی ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.",
}, },
google: { google: {
loadingTitle: "در حال تکمیل ورود با گوگل", loadingTitle: "در حال تکمیل ورود با گوگل",
loadingDescription: "در حال بررسی حساب گوگل شما و آماده‌سازی مرحله بعد هستیم.", loadingDescription: "در حال بررسی حساب گوگل شما و آماده‌سازی مرحله بعد هستیم.",
collectMobileTitle: "ساخت حساب را کامل کنید", collectMobileTitle: "ساخت حساب را کامل کنید",
collectMobileDescription: (email: string) => collectMobileDescription: (email: string) =>
`حساب گوگل ${email} تایید شد. برای تکمیل ساخت حساب، شماره موبایل خود را وارد کنید.`, `حساب گوگل ${email} تایید شد. برای تکمیل ساخت حساب، شماره موبایل خود را وارد کنید.`,
claimTitle: "حساب موجود خود را تایید کنید", existingEmailClaimDescription: (email: string, mobileHint: string) =>
claimDescription: (mobile: string) => `حساب گوگل ${email} تایید شد. برای تایید مالکیت، شماره موبایل متصل به این حساب (${mobileHint}) را وارد کنید.`,
`حسابی با شماره ${mobile} از قبل وجود دارد. کد ارسال‌شده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`, claimTitle: "حساب موجود خود را تایید کنید",
errorTitle: "ورود با گوگل کامل نشد", claimDescription: (mobile: string) =>
missingFlow: "جریان ورود با گوگل پیدا نشد یا منقضی شده است.", `حسابی با شماره ${mobile} از قبل وجود دارد. کد ارسال‌شده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
loadFailed: "بارگذاری وضعیت ورود با گوگل انجام نشد.", mobileClaimDescription: (mobile: string) =>
completeFailed: "تکمیل ساخت حساب با گوگل انجام نشد.", `حسابی بدون ایمیل با شماره ${mobile} از قبل وجود دارد. کد ارسال‌شده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
claimOtpSent: "کد تایید با موفقیت ارسال شد.", errorTitle: "ورود با گوگل کامل نشد",
googleAccount: "حساب گوگل", missingFlow: "جریان ورود با گوگل پیدا نشد یا منقضی شده است.",
completeButton: "ادامه و ایجاد حساب", loadFailed: "بارگذاری وضعیت ورود با گوگل انجام نشد.",
verifyClaimButton: "تایید و ادامه", completeFailed: "تکمیل ساخت حساب با گوگل انجام نشد.",
resendClaimOtp: "ارسال دوباره کد تایید", claimOtpSent: "کد تایید با موفقیت ارسال شد.",
restartGoogle: "شروع دوباره ورود با گوگل", googleAccount: "حساب گوگل",
mobileHintLabel: (mobileHint: string) => `شماره مورد انتظار: ${mobileHint}`,
completeButton: "ادامه و ایجاد حساب",
verifyClaimButton: "تایید و ادامه",
resendClaimOtp: "ارسال دوباره کد تایید",
restartGoogle: "شروع دوباره ورود با گوگل",
} }
}, },

View File

@@ -39,6 +39,10 @@ export default function GoogleAuthCallback() {
const [mobile, setMobile] = useState(""); const [mobile, setMobile] = useState("");
const [otpCode, setOtpCode] = useState(""); const [otpCode, setOtpCode] = useState("");
const [googleEmail, setGoogleEmail] = useState(""); const [googleEmail, setGoogleEmail] = useState("");
const [flowResolution, setFlowResolution] = useState<
"new_account" | "existing_email_claim" | "existing_mobile_claim" | null
>(null);
const [mobileHint, setMobileHint] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [cooldowns, setCooldowns] = useState<Record<CooldownKey, number>>({ const [cooldowns, setCooldowns] = useState<Record<CooldownKey, number>>({
otpSend: 0, otpSend: 0,
@@ -93,12 +97,19 @@ export default function GoogleAuthCallback() {
if (payload.status === "collect_mobile") { if (payload.status === "collect_mobile") {
setGoogleEmail(payload.email); setGoogleEmail(payload.email);
setFlowResolution(payload.resolution);
setMobileHint(payload.mobile_hint ?? null);
setErrorMessage("");
setStep("collect_mobile"); setStep("collect_mobile");
return; return;
} }
if (payload.status === "claim_required") { if (payload.status === "claim_required") {
setMobile(payload.mobile); setMobile(payload.mobile);
setGoogleEmail(payload.email);
setFlowResolution(payload.resolution);
setMobileHint(payload.mobile_hint ?? null);
setErrorMessage("");
setStep("claim_required"); setStep("claim_required");
} }
}; };
@@ -174,7 +185,14 @@ export default function GoogleAuthCallback() {
toast.success(t.login.google.claimOtpSent); toast.success(t.login.google.claimOtpSent);
} }
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : t.login.google.completeFailed); const message = error instanceof Error ? error.message : t.login.google.completeFailed;
setErrorMessage(message);
if (error instanceof ApiError) {
if (error.code === "google_email_mobile_conflict" || error.code === "google_mobile_belongs_to_other_email") {
setStep("collect_mobile");
}
}
toast.error(message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -261,12 +279,26 @@ export default function GoogleAuthCallback() {
<p className="text-sm text-slate-500 dark:text-slate-400"> <p className="text-sm text-slate-500 dark:text-slate-400">
{step === "loading" && t.login.google.loadingDescription} {step === "loading" && t.login.google.loadingDescription}
{step === "collect_mobile" && {step === "collect_mobile" &&
t.login.google.collectMobileDescription(googleEmail || "-")} (flowResolution === "existing_email_claim"
{step === "claim_required" && t.login.google.claimDescription(mobile)} ? t.login.google.existingEmailClaimDescription(googleEmail || "-", mobileHint || "-")
: t.login.google.collectMobileDescription(googleEmail || "-"))}
{step === "claim_required" &&
(flowResolution === "existing_email_claim"
? t.login.google.claimDescription(mobileHint || mobile)
: t.login.google.mobileClaimDescription(mobile))}
{step === "error" && (errorMessage || t.login.google.loadFailed)} {step === "error" && (errorMessage || t.login.google.loadFailed)}
</p> </p>
</div> </div>
{errorMessage && step !== "error" && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-start text-red-800 shadow-sm dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-100">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="text-sm">{errorMessage}</p>
</div>
</div>
)}
{activeWarning && ( {activeWarning && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-start text-amber-900 shadow-sm dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100"> <div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-start text-amber-900 shadow-sm dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@@ -303,13 +335,21 @@ export default function GoogleAuthCallback() {
<p className="truncate text-sm text-slate-500 dark:text-slate-400">{googleEmail}</p> <p className="truncate text-sm text-slate-500 dark:text-slate-400">{googleEmail}</p>
</div> </div>
</div> </div>
{flowResolution === "existing_email_claim" && mobileHint && (
<div className="mb-4 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800 dark:border-sky-900/50 dark:bg-sky-950/30 dark:text-sky-100">
{t.login.google.mobileHintLabel(mobileHint)}
</div>
)}
<Input <Input
id="google-mobile" id="google-mobile"
placeholder={t.login.mobilePlaceholder} placeholder={t.login.mobilePlaceholder}
type="tel" type="tel"
dir="ltr" dir="ltr"
value={mobile} value={mobile}
onChange={(event) => setMobile(event.target.value)} onChange={(event) => {
setMobile(event.target.value)
setErrorMessage("")
}}
maxLength={11} maxLength={11}
disabled={loading} disabled={loading}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`} className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
@@ -330,7 +370,9 @@ export default function GoogleAuthCallback() {
<form onSubmit={handleVerifyClaim} className="grid gap-4"> <form onSubmit={handleVerifyClaim} className="grid gap-4">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"> <div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<p className="mb-3 text-sm text-slate-500 dark:text-slate-400"> <p className="mb-3 text-sm text-slate-500 dark:text-slate-400">
{t.login.google.claimDescription(mobile)} {flowResolution === "existing_email_claim"
? t.login.google.claimDescription(mobileHint || mobile)
: t.login.google.mobileClaimDescription(mobile)}
</p> </p>
<Input <Input
id="google-claim-otp" id="google-claim-otp"
@@ -338,7 +380,10 @@ export default function GoogleAuthCallback() {
type="text" type="text"
dir="ltr" dir="ltr"
value={otpCode} value={otpCode}
onChange={(event) => setOtpCode(event.target.value)} onChange={(event) => {
setOtpCode(event.target.value)
setErrorMessage("")
}}
maxLength={6} maxLength={6}
disabled={loading} disabled={loading}
className="h-11 text-center text-lg tracking-widest" className="h-11 text-center text-lg tracking-widest"