feat(auth): add google sign-in onboarding flow

This commit is contained in:
2026-05-01 01:54:26 +03:30
parent 2aa4b2b4cd
commit b688bb1ec3
7 changed files with 540 additions and 28 deletions

View File

@@ -0,0 +1,392 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { AlertTriangle, ArrowLeft, ArrowRight, CheckCircle2, Command, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { SettingsMenu } from "../components/SettingsMenu";
import { ApiError } from "../api/client";
import {
completeGoogleOAuthSignup,
getGoogleOAuthFlow,
sendGoogleOAuthClaimOtp,
startGoogleLogin,
verifyGoogleOAuthClaim,
type GoogleOAuthFlowResponse,
} from "../api/users";
import { useTranslation } from "../hooks/useTranslation";
import { setSessionTokens } from "../lib/session";
type GoogleStep = "loading" | "collect_mobile" | "claim_required" | "error";
type CooldownKey = "otpSend" | "otpVerify";
const PERSIAN_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
const toPersianDigits = (value: string) =>
value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number.parseInt(digit, 10)] ?? digit);
export default function GoogleAuthCallback() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { t, lang } = useTranslation();
const isRtl = lang === "fa";
const flow = searchParams.get("flow") ?? "";
const [step, setStep] = useState<GoogleStep>("loading");
const [loading, setLoading] = useState(false);
const [mobile, setMobile] = useState("");
const [otpCode, setOtpCode] = useState("");
const [googleEmail, setGoogleEmail] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const [cooldowns, setCooldowns] = useState<Record<CooldownKey, number>>({
otpSend: 0,
otpVerify: 0,
});
useEffect(() => {
if (!Object.values(cooldowns).some((value) => value > 0)) {
return;
}
const timer = window.setInterval(() => {
setCooldowns((current) => ({
otpSend: Math.max(0, current.otpSend - 1),
otpVerify: Math.max(0, current.otpVerify - 1),
}));
}, 1000);
return () => window.clearInterval(timer);
}, [cooldowns]);
const localizeDigits = (value: string) => (isRtl ? toPersianDigits(value) : value);
const formatCooldown = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
const formatted =
minutes > 0
? `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`
: `${remainingSeconds}s`;
return localizeDigits(formatted);
};
const setCooldown = (key: CooldownKey, seconds: number) => {
setCooldowns((current) => ({
...current,
[key]: Math.max(current[key], seconds),
}));
};
const handleAuthenticated = (payload: Extract<GoogleOAuthFlowResponse, { status: "authenticated" }>) => {
setSessionTokens(payload.access, payload.refresh);
toast.success(t.login.toasts.successLogin);
navigate("/profile", { replace: true });
};
const applyFlowPayload = (payload: GoogleOAuthFlowResponse) => {
if (payload.status === "authenticated") {
handleAuthenticated(payload);
return;
}
if (payload.status === "collect_mobile") {
setGoogleEmail(payload.email);
setStep("collect_mobile");
return;
}
if (payload.status === "claim_required") {
setMobile(payload.mobile);
setStep("claim_required");
}
};
const handleThrottleError = (error: unknown, key: CooldownKey) => {
if (!(error instanceof ApiError) || error.code !== "throttled") {
return false;
}
const seconds = Math.max(1, error.retryAfterSeconds ?? 0);
const formattedTime = formatCooldown(seconds);
setCooldown(key, seconds);
const message =
key === "otpSend"
? t.login.throttle.otpSendMessage(formattedTime)
: t.login.throttle.otpLoginMessage(formattedTime);
toast.error(message, {
description: t.login.throttle.countdownLabel(formattedTime),
});
return true;
};
useEffect(() => {
if (!flow) {
setErrorMessage(t.login.google.missingFlow);
setStep("error");
return;
}
let cancelled = false;
const loadFlow = async () => {
setLoading(true);
try {
const payload = await getGoogleOAuthFlow(flow);
if (!cancelled) {
applyFlowPayload(payload);
}
} catch (error) {
if (!cancelled) {
setErrorMessage(error instanceof Error ? error.message : t.login.google.loadFailed);
setStep("error");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
loadFlow();
return () => {
cancelled = true;
};
}, [flow]);
const handleCompleteSignup = async (event: React.FormEvent) => {
event.preventDefault();
if (!mobile) {
toast.error(t.login.toasts.enterMobile);
return;
}
setLoading(true);
try {
const payload = await completeGoogleOAuthSignup(flow, mobile);
applyFlowPayload(payload);
if (payload.status === "claim_required") {
toast.success(t.login.google.claimOtpSent);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t.login.google.completeFailed);
} finally {
setLoading(false);
}
};
const handleResendClaimOtp = async () => {
setLoading(true);
try {
await sendGoogleOAuthClaimOtp(flow);
setCooldowns((current) => ({ ...current, otpSend: 0 }));
toast.success(t.login.google.claimOtpSent);
} catch (error) {
if (!handleThrottleError(error, "otpSend")) {
toast.error(error instanceof Error ? error.message : t.login.toasts.failedOtp);
}
} finally {
setLoading(false);
}
};
const handleVerifyClaim = async (event: React.FormEvent) => {
event.preventDefault();
if (!otpCode) {
toast.error(t.login.toasts.enterOtp);
return;
}
setLoading(true);
try {
const payload = await verifyGoogleOAuthClaim(flow, otpCode);
setCooldowns((current) => ({ ...current, otpVerify: 0 }));
applyFlowPayload(payload);
} catch (error) {
if (!handleThrottleError(error, "otpVerify")) {
toast.error(error instanceof Error ? error.message : t.login.toasts.invalidOtp);
}
} finally {
setLoading(false);
}
};
const activeWarning = useMemo(() => {
if (cooldowns.otpSend > 0) {
return t.login.throttle.otpSendMessage(formatCooldown(cooldowns.otpSend));
}
if (cooldowns.otpVerify > 0) {
return t.login.throttle.otpLoginMessage(formatCooldown(cooldowns.otpVerify));
}
return null;
}, [cooldowns, isRtl, lang]);
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
return (
<div className="container relative min-h-screen grid flex-col items-center justify-center bg-white transition-colors lg:max-w-none lg:grid-cols-2 lg:px-0 dark:bg-slate-950">
<div className="absolute inset-e-4 top-4 z-50 md:inset-e-8 md:top-8">
<SettingsMenu />
</div>
<div className="relative hidden h-full flex-col border-e border-slate-200 bg-slate-900 p-10 text-white dark:border-slate-800 dark:bg-slate-900/50 lg:flex">
<div className="relative z-20 flex items-center gap-2 text-lg font-medium">
<Command className="h-6 w-6" />
{t.title || "Qlockify"}
</div>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">"{t.login.brandingQuote}"</p>
</blockquote>
</div>
</div>
<div className="flex h-screen items-center justify-center p-8 lg:p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[420px]">
<div className="flex flex-col space-y-2 text-center text-slate-900 dark:text-slate-50">
<div className="mb-4 flex justify-center lg:hidden">
<Command className="h-8 w-8" />
</div>
<h1 className="text-2xl font-semibold tracking-tight">
{step === "loading" && t.login.google.loadingTitle}
{step === "collect_mobile" && t.login.google.collectMobileTitle}
{step === "claim_required" && t.login.google.claimTitle}
{step === "error" && t.login.google.errorTitle}
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
{step === "loading" && t.login.google.loadingDescription}
{step === "collect_mobile" &&
t.login.google.collectMobileDescription(googleEmail || "-")}
{step === "claim_required" && t.login.google.claimDescription(mobile)}
{step === "error" && (errorMessage || t.login.google.loadFailed)}
</p>
</div>
{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="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium">{t.login.throttle.title}</p>
<p className="text-sm">{activeWarning}</p>
</div>
</div>
</div>
)}
<div className="grid gap-6">
{step === "loading" && (
<div className="rounded-3xl border border-slate-200 bg-white p-8 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
<Loader2 className="mx-auto mb-4 h-8 w-8 animate-spin text-slate-400" />
<p className="text-sm text-slate-500 dark:text-slate-400">
{t.login.google.loadingDescription}
</p>
</div>
)}
{step === "collect_mobile" && (
<form onSubmit={handleCompleteSignup} 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="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200">
<CheckCircle2 className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-white">
{t.login.google.googleAccount}
</p>
<p className="truncate text-sm text-slate-500 dark:text-slate-400">{googleEmail}</p>
</div>
</div>
<Input
id="google-mobile"
placeholder={t.login.mobilePlaceholder}
type="tel"
dir="ltr"
value={mobile}
onChange={(event) => setMobile(event.target.value)}
maxLength={11}
disabled={loading}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
/>
</div>
<Button type="submit" className="h-11 w-full" disabled={loading}>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{t.login.google.completeButton}
</Button>
<Button type="button" variant="ghost" onClick={() => startGoogleLogin()} className="text-sm text-slate-500 dark:text-slate-400">
{t.login.google.restartGoogle}
</Button>
</form>
)}
{step === "claim_required" && (
<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">
<p className="mb-3 text-sm text-slate-500 dark:text-slate-400">
{t.login.google.claimDescription(mobile)}
</p>
<Input
id="google-claim-otp"
placeholder={t.login.otpPlaceholder}
type="text"
dir="ltr"
value={otpCode}
onChange={(event) => setOtpCode(event.target.value)}
maxLength={6}
disabled={loading}
className="h-11 text-center text-lg tracking-widest"
/>
</div>
<Button type="submit" className="h-11 w-full" disabled={loading || cooldowns.otpVerify > 0}>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{cooldowns.otpVerify > 0
? t.login.throttle.countdownLabel(formatCooldown(cooldowns.otpVerify))
: t.login.google.verifyClaimButton}
</Button>
<Button
type="button"
variant="outline"
onClick={handleResendClaimOtp}
disabled={loading || cooldowns.otpSend > 0}
className="h-11"
>
{cooldowns.otpSend > 0
? t.login.throttle.countdownLabel(formatCooldown(cooldowns.otpSend))
: t.login.google.resendClaimOtp}
</Button>
</form>
)}
{step === "error" && (
<div className="rounded-3xl border border-red-200 bg-red-50 p-6 text-center shadow-sm dark:border-red-900/50 dark:bg-red-950/20">
<AlertTriangle className="mx-auto mb-3 h-8 w-8 text-red-500" />
<p className="mb-4 text-sm text-red-700 dark:text-red-200">
{errorMessage || t.login.google.loadFailed}
</p>
<div className="grid gap-3">
<Button type="button" onClick={() => startGoogleLogin()} className="h-11">
{t.login.google.restartGoogle}
</Button>
<Button type="button" variant="ghost" asChild className="text-sm text-slate-500 dark:text-slate-400">
<Link to="/auth">
<BackIcon className="me-2 h-4 w-4" />
{t.login.back}
</Link>
</Button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}