Files
qlockify-frontend-deployment/src/pages/GoogleAuthCallback.tsx

393 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}