feat(auth): handle google oauth account claim conflicts
This commit is contained in:
@@ -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> => {
|
||||||
|
|||||||
@@ -115,15 +115,20 @@ export const en = {
|
|||||||
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.`,
|
||||||
|
existingEmailClaimDescription: (email: string, mobileHint: string) =>
|
||||||
|
`Google verified ${email}. Enter the mobile number already connected to this account (${mobileHint}) to confirm ownership.`,
|
||||||
claimTitle: "Verify your existing account",
|
claimTitle: "Verify your existing account",
|
||||||
claimDescription: (mobile: string) =>
|
claimDescription: (mobile: string) =>
|
||||||
`An account with ${mobile} already exists. Enter the verification code sent to that number to attach Google.`,
|
`An account with ${mobile} already exists. Enter the verification code sent to that number to attach Google.`,
|
||||||
|
mobileClaimDescription: (mobile: string) =>
|
||||||
|
`A mobile-only account with ${mobile} already exists. Verify the code sent to that number to attach Google and keep that account.`,
|
||||||
errorTitle: "Google sign in could not be completed",
|
errorTitle: "Google sign in could not be completed",
|
||||||
missingFlow: "The Google sign-in flow is missing or has expired.",
|
missingFlow: "The Google sign-in flow is missing or has expired.",
|
||||||
loadFailed: "We could not load your Google sign-in state.",
|
loadFailed: "We could not load your Google sign-in state.",
|
||||||
completeFailed: "We could not finish your Google account setup.",
|
completeFailed: "We could not finish your Google account setup.",
|
||||||
claimOtpSent: "Verification code sent successfully.",
|
claimOtpSent: "Verification code sent successfully.",
|
||||||
googleAccount: "Google account",
|
googleAccount: "Google account",
|
||||||
|
mobileHintLabel: (mobileHint: string) => `Expected mobile: ${mobileHint}`,
|
||||||
completeButton: "Continue and create account",
|
completeButton: "Continue and create account",
|
||||||
verifyClaimButton: "Verify and continue",
|
verifyClaimButton: "Verify and continue",
|
||||||
resendClaimOtp: "Resend verification code",
|
resendClaimOtp: "Resend verification code",
|
||||||
|
|||||||
@@ -115,15 +115,20 @@ export const fa = {
|
|||||||
collectMobileTitle: "ساخت حساب را کامل کنید",
|
collectMobileTitle: "ساخت حساب را کامل کنید",
|
||||||
collectMobileDescription: (email: string) =>
|
collectMobileDescription: (email: string) =>
|
||||||
`حساب گوگل ${email} تایید شد. برای تکمیل ساخت حساب، شماره موبایل خود را وارد کنید.`,
|
`حساب گوگل ${email} تایید شد. برای تکمیل ساخت حساب، شماره موبایل خود را وارد کنید.`,
|
||||||
|
existingEmailClaimDescription: (email: string, mobileHint: string) =>
|
||||||
|
`حساب گوگل ${email} تایید شد. برای تایید مالکیت، شماره موبایل متصل به این حساب (${mobileHint}) را وارد کنید.`,
|
||||||
claimTitle: "حساب موجود خود را تایید کنید",
|
claimTitle: "حساب موجود خود را تایید کنید",
|
||||||
claimDescription: (mobile: string) =>
|
claimDescription: (mobile: string) =>
|
||||||
`حسابی با شماره ${mobile} از قبل وجود دارد. کد ارسالشده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
|
`حسابی با شماره ${mobile} از قبل وجود دارد. کد ارسالشده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
|
||||||
|
mobileClaimDescription: (mobile: string) =>
|
||||||
|
`حسابی بدون ایمیل با شماره ${mobile} از قبل وجود دارد. کد ارسالشده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
|
||||||
errorTitle: "ورود با گوگل کامل نشد",
|
errorTitle: "ورود با گوگل کامل نشد",
|
||||||
missingFlow: "جریان ورود با گوگل پیدا نشد یا منقضی شده است.",
|
missingFlow: "جریان ورود با گوگل پیدا نشد یا منقضی شده است.",
|
||||||
loadFailed: "بارگذاری وضعیت ورود با گوگل انجام نشد.",
|
loadFailed: "بارگذاری وضعیت ورود با گوگل انجام نشد.",
|
||||||
completeFailed: "تکمیل ساخت حساب با گوگل انجام نشد.",
|
completeFailed: "تکمیل ساخت حساب با گوگل انجام نشد.",
|
||||||
claimOtpSent: "کد تایید با موفقیت ارسال شد.",
|
claimOtpSent: "کد تایید با موفقیت ارسال شد.",
|
||||||
googleAccount: "حساب گوگل",
|
googleAccount: "حساب گوگل",
|
||||||
|
mobileHintLabel: (mobileHint: string) => `شماره مورد انتظار: ${mobileHint}`,
|
||||||
completeButton: "ادامه و ایجاد حساب",
|
completeButton: "ادامه و ایجاد حساب",
|
||||||
verifyClaimButton: "تایید و ادامه",
|
verifyClaimButton: "تایید و ادامه",
|
||||||
resendClaimOtp: "ارسال دوباره کد تایید",
|
resendClaimOtp: "ارسال دوباره کد تایید",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user