393 lines
14 KiB
TypeScript
393 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|