feat(auth): add google sign-in onboarding flow
This commit is contained in:
@@ -8,6 +8,7 @@ import { Sidebar } from './components/Sidebar';
|
||||
import { NotificationsProvider } from "./context/NotificationsContext"
|
||||
import { WorkspaceProvider } from "./context/WorkspaceContext"
|
||||
import Auth from "./pages/Auth"
|
||||
import GoogleAuthCallback from "./pages/GoogleAuthCallback"
|
||||
import Profile from "./pages/Profile"
|
||||
import Terms from "./pages/Terms"
|
||||
import Workspaces from "./pages/Workspaces"
|
||||
@@ -81,6 +82,7 @@ const router = createBrowserRouter([
|
||||
{ path: "/", element: <Landing /> },
|
||||
{ path: "/app", element: <AppRedirect /> },
|
||||
{ path: "/auth", element: <Auth /> },
|
||||
{ path: "/auth/google/callback", element: <GoogleAuthCallback /> },
|
||||
{ path: "/terms", element: <Terms /> },
|
||||
{ path: "/rate-limit", element: <RateLimitPage /> },
|
||||
{
|
||||
|
||||
@@ -52,7 +52,7 @@ export class ApiError extends Error {
|
||||
|
||||
const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "")
|
||||
|
||||
const buildUrl = (endpoint: string) => {
|
||||
export const buildApiUrl = (endpoint: string) => {
|
||||
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`
|
||||
return `${cleanBaseUrl}${cleanEndpoint}`
|
||||
}
|
||||
@@ -154,7 +154,7 @@ const refreshAccessToken = async () => {
|
||||
|
||||
if (!refreshRequest) {
|
||||
refreshRequest = (async () => {
|
||||
const response = await fetch(buildUrl("/api/users/token/refresh/"), {
|
||||
const response = await fetch(buildApiUrl("/api/users/token/refresh/"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -226,7 +226,7 @@ export const authFetch = async (endpoint: string, options: RequestInit = {}, all
|
||||
...options.headers,
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(endpoint), {
|
||||
const response = await fetch(buildApiUrl(endpoint), {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authFetch, buildApiError } from './client';
|
||||
import { authFetch, buildApiError, buildApiUrl } from './client';
|
||||
|
||||
// --- Auth Endpoints ---
|
||||
|
||||
@@ -29,6 +29,70 @@ export const loginWithOtp = async (mobile: string, otp: string) => {
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const startGoogleLogin = () => {
|
||||
window.location.assign(buildApiUrl("/api/users/oauth/google/start/"));
|
||||
};
|
||||
|
||||
export type GoogleOAuthFlowResponse =
|
||||
| {
|
||||
status: "authenticated";
|
||||
access: string;
|
||||
refresh: string;
|
||||
}
|
||||
| {
|
||||
status: "collect_mobile";
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
avatar_url: string;
|
||||
}
|
||||
| {
|
||||
status: "claim_required";
|
||||
mobile: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export const getGoogleOAuthFlow = async (flow: string): Promise<GoogleOAuthFlowResponse> => {
|
||||
const response = await authFetch(`/api/users/oauth/google/flow/?flow=${encodeURIComponent(flow)}`, {
|
||||
method: "GET",
|
||||
});
|
||||
if (!response.ok) throw await buildApiError(response);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const completeGoogleOAuthSignup = async (
|
||||
flow: string,
|
||||
mobile: string,
|
||||
): Promise<GoogleOAuthFlowResponse> => {
|
||||
const response = await authFetch("/api/users/oauth/google/complete/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ flow, mobile }),
|
||||
});
|
||||
if (!response.ok) throw await buildApiError(response);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const sendGoogleOAuthClaimOtp = async (flow: string) => {
|
||||
const response = await authFetch("/api/users/oauth/google/claim/send-otp/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ flow }),
|
||||
});
|
||||
if (!response.ok) throw await buildApiError(response);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const verifyGoogleOAuthClaim = async (
|
||||
flow: string,
|
||||
code: string,
|
||||
): Promise<GoogleOAuthFlowResponse> => {
|
||||
const response = await authFetch("/api/users/oauth/google/claim/verify/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ flow, code }),
|
||||
});
|
||||
if (!response.ok) throw await buildApiError(response);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const logoutUser = async (refreshToken: string) => {
|
||||
const response = await authFetch('/api/users/logout/', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const en = {
|
||||
save: "Save",
|
||||
lightMode: "Light Mode",
|
||||
darkMode: "Dark Mode",
|
||||
settings: "Settings",
|
||||
loadingText: "Loading...",
|
||||
loading: "Loading...",
|
||||
add: "Add",
|
||||
@@ -33,6 +34,7 @@ export const en = {
|
||||
sentCodeDesc: (mobile: string) => `We sent a 6-digit code to ${mobile}`,
|
||||
mobilePlaceholder: "Mobile Number (e.g. 09123456789)",
|
||||
continueWithPassword: "Continue with Password",
|
||||
continueWithGoogle: "Continue with Google",
|
||||
orContinueWith: "Or continue with",
|
||||
otpLogin: "OTP Login",
|
||||
register: "Register",
|
||||
@@ -61,7 +63,27 @@ export const en = {
|
||||
otpLoginMessage: (time: string) => `Too many OTP login attempts. Try again in ${time}.`,
|
||||
countdownLabel: (time: string) => `Retry in ${time}`,
|
||||
fallback: "Too many requests. Please wait and try again.",
|
||||
}
|
||||
},
|
||||
google: {
|
||||
loadingTitle: "Completing Google sign in",
|
||||
loadingDescription: "We are validating your Google account and preparing the next step.",
|
||||
collectMobileTitle: "Finish your account setup",
|
||||
collectMobileDescription: (email: string) =>
|
||||
`Google verified ${email}. Enter your mobile number to finish creating your account.`,
|
||||
claimTitle: "Verify your existing account",
|
||||
claimDescription: (mobile: string) =>
|
||||
`An account with ${mobile} already exists. Enter the verification code sent to that number to attach Google.`,
|
||||
errorTitle: "Google sign in could not be completed",
|
||||
missingFlow: "The Google sign-in flow is missing or has expired.",
|
||||
loadFailed: "We could not load your Google sign-in state.",
|
||||
completeFailed: "We could not finish your Google account setup.",
|
||||
claimOtpSent: "Verification code sent successfully.",
|
||||
googleAccount: "Google account",
|
||||
completeButton: "Continue and create account",
|
||||
verifyClaimButton: "Verify and continue",
|
||||
resendClaimOtp: "Resend verification code",
|
||||
restartGoogle: "Start Google sign in again",
|
||||
},
|
||||
},
|
||||
|
||||
loginTerms: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export const fa = {
|
||||
remove: "حذف",
|
||||
lightMode: "حالت روشن",
|
||||
darkMode: "حالت تاریک",
|
||||
settings: "تنظیمات",
|
||||
loadingText: "در حال بارگذاری...",
|
||||
loading: "در حال بارگذاری...",
|
||||
noMoreResults: "نتیجه دیگری نیست.",
|
||||
@@ -33,6 +34,7 @@ export const fa = {
|
||||
sentCodeDesc: (mobile: string) => `کد ۶ رقمی به ${mobile} ارسال شد`,
|
||||
mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)",
|
||||
continueWithPassword: "ادامه با رمز عبور",
|
||||
continueWithGoogle: "ادامه با گوگل",
|
||||
orContinueWith: "یا ادامه با",
|
||||
otpLogin: "ورود با کد یکبار مصرف",
|
||||
register: "ثبت نام",
|
||||
@@ -61,6 +63,26 @@ export const fa = {
|
||||
otpLoginMessage: (time: string) => `تلاش برای ورود با کد یکبار مصرف بیش از حد مجاز بوده است. ${time} دیگر دوباره تلاش کنید.`,
|
||||
countdownLabel: (time: string) => `تلاش دوباره تا ${time}`,
|
||||
fallback: "درخواستهای زیادی ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.",
|
||||
},
|
||||
google: {
|
||||
loadingTitle: "در حال تکمیل ورود با گوگل",
|
||||
loadingDescription: "در حال بررسی حساب گوگل شما و آمادهسازی مرحله بعد هستیم.",
|
||||
collectMobileTitle: "ساخت حساب را کامل کنید",
|
||||
collectMobileDescription: (email: string) =>
|
||||
`حساب گوگل ${email} تایید شد. برای تکمیل ساخت حساب، شماره موبایل خود را وارد کنید.`,
|
||||
claimTitle: "حساب موجود خود را تایید کنید",
|
||||
claimDescription: (mobile: string) =>
|
||||
`حسابی با شماره ${mobile} از قبل وجود دارد. کد ارسالشده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
|
||||
errorTitle: "ورود با گوگل کامل نشد",
|
||||
missingFlow: "جریان ورود با گوگل پیدا نشد یا منقضی شده است.",
|
||||
loadFailed: "بارگذاری وضعیت ورود با گوگل انجام نشد.",
|
||||
completeFailed: "تکمیل ساخت حساب با گوگل انجام نشد.",
|
||||
claimOtpSent: "کد تایید با موفقیت ارسال شد.",
|
||||
googleAccount: "حساب گوگل",
|
||||
completeButton: "ادامه و ایجاد حساب",
|
||||
verifyClaimButton: "تایید و ادامه",
|
||||
resendClaimOtp: "ارسال دوباره کد تایید",
|
||||
restartGoogle: "شروع دوباره ورود با گوگل",
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SettingsMenu } from "../components/SettingsMenu"
|
||||
import { AlertTriangle, ArrowLeft, ArrowRight, Command, Eye, EyeOff, Loader2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslation } from "../hooks/useTranslation"
|
||||
import { loginWithOtp, loginWithPassword, sendOtp } from "../api/users"
|
||||
import { loginWithOtp, loginWithPassword, sendOtp, startGoogleLogin } from "../api/users"
|
||||
import { ApiError } from "../api/client"
|
||||
import { setSessionTokens } from "../lib/session"
|
||||
|
||||
@@ -299,6 +299,16 @@ export default function Auth() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={startGoogleLogin}
|
||||
disabled={loading}
|
||||
className="h-11 w-full"
|
||||
>
|
||||
{t.login.continueWithGoogle}
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
392
src/pages/GoogleAuthCallback.tsx
Normal file
392
src/pages/GoogleAuthCallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user