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 { NotificationsProvider } from "./context/NotificationsContext"
|
||||||
import { WorkspaceProvider } from "./context/WorkspaceContext"
|
import { WorkspaceProvider } from "./context/WorkspaceContext"
|
||||||
import Auth from "./pages/Auth"
|
import Auth from "./pages/Auth"
|
||||||
|
import GoogleAuthCallback from "./pages/GoogleAuthCallback"
|
||||||
import Profile from "./pages/Profile"
|
import Profile from "./pages/Profile"
|
||||||
import Terms from "./pages/Terms"
|
import Terms from "./pages/Terms"
|
||||||
import Workspaces from "./pages/Workspaces"
|
import Workspaces from "./pages/Workspaces"
|
||||||
@@ -81,6 +82,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: "/", element: <Landing /> },
|
{ path: "/", element: <Landing /> },
|
||||||
{ path: "/app", element: <AppRedirect /> },
|
{ path: "/app", element: <AppRedirect /> },
|
||||||
{ path: "/auth", element: <Auth /> },
|
{ path: "/auth", element: <Auth /> },
|
||||||
|
{ path: "/auth/google/callback", element: <GoogleAuthCallback /> },
|
||||||
{ path: "/terms", element: <Terms /> },
|
{ path: "/terms", element: <Terms /> },
|
||||||
{ path: "/rate-limit", element: <RateLimitPage /> },
|
{ path: "/rate-limit", element: <RateLimitPage /> },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class ApiError extends Error {
|
|||||||
|
|
||||||
const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "")
|
const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "")
|
||||||
|
|
||||||
const buildUrl = (endpoint: string) => {
|
export const buildApiUrl = (endpoint: string) => {
|
||||||
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`
|
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`
|
||||||
return `${cleanBaseUrl}${cleanEndpoint}`
|
return `${cleanBaseUrl}${cleanEndpoint}`
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ const refreshAccessToken = async () => {
|
|||||||
|
|
||||||
if (!refreshRequest) {
|
if (!refreshRequest) {
|
||||||
refreshRequest = (async () => {
|
refreshRequest = (async () => {
|
||||||
const response = await fetch(buildUrl("/api/users/token/refresh/"), {
|
const response = await fetch(buildApiUrl("/api/users/token/refresh/"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -226,7 +226,7 @@ export const authFetch = async (endpoint: string, options: RequestInit = {}, all
|
|||||||
...options.headers,
|
...options.headers,
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(buildUrl(endpoint), {
|
const response = await fetch(buildApiUrl(endpoint), {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { authFetch, buildApiError } from './client';
|
import { authFetch, buildApiError, buildApiUrl } from './client';
|
||||||
|
|
||||||
// --- Auth Endpoints ---
|
// --- Auth Endpoints ---
|
||||||
|
|
||||||
@@ -29,6 +29,70 @@ export const loginWithOtp = async (mobile: string, otp: string) => {
|
|||||||
return response.json();
|
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) => {
|
export const logoutUser = async (refreshToken: string) => {
|
||||||
const response = await authFetch('/api/users/logout/', {
|
const response = await authFetch('/api/users/logout/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const en = {
|
|||||||
save: "Save",
|
save: "Save",
|
||||||
lightMode: "Light Mode",
|
lightMode: "Light Mode",
|
||||||
darkMode: "Dark Mode",
|
darkMode: "Dark Mode",
|
||||||
|
settings: "Settings",
|
||||||
loadingText: "Loading...",
|
loadingText: "Loading...",
|
||||||
loading: "Loading...",
|
loading: "Loading...",
|
||||||
add: "Add",
|
add: "Add",
|
||||||
@@ -33,6 +34,7 @@ export const en = {
|
|||||||
sentCodeDesc: (mobile: string) => `We sent a 6-digit code to ${mobile}`,
|
sentCodeDesc: (mobile: string) => `We sent a 6-digit code to ${mobile}`,
|
||||||
mobilePlaceholder: "Mobile Number (e.g. 09123456789)",
|
mobilePlaceholder: "Mobile Number (e.g. 09123456789)",
|
||||||
continueWithPassword: "Continue with Password",
|
continueWithPassword: "Continue with Password",
|
||||||
|
continueWithGoogle: "Continue with Google",
|
||||||
orContinueWith: "Or continue with",
|
orContinueWith: "Or continue with",
|
||||||
otpLogin: "OTP Login",
|
otpLogin: "OTP Login",
|
||||||
register: "Register",
|
register: "Register",
|
||||||
@@ -61,7 +63,27 @@ export const en = {
|
|||||||
otpLoginMessage: (time: string) => `Too many OTP login attempts. Try again in ${time}.`,
|
otpLoginMessage: (time: string) => `Too many OTP login attempts. Try again in ${time}.`,
|
||||||
countdownLabel: (time: string) => `Retry in ${time}`,
|
countdownLabel: (time: string) => `Retry in ${time}`,
|
||||||
fallback: "Too many requests. Please wait and try again.",
|
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: {
|
loginTerms: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const fa = {
|
|||||||
remove: "حذف",
|
remove: "حذف",
|
||||||
lightMode: "حالت روشن",
|
lightMode: "حالت روشن",
|
||||||
darkMode: "حالت تاریک",
|
darkMode: "حالت تاریک",
|
||||||
|
settings: "تنظیمات",
|
||||||
loadingText: "در حال بارگذاری...",
|
loadingText: "در حال بارگذاری...",
|
||||||
loading: "در حال بارگذاری...",
|
loading: "در حال بارگذاری...",
|
||||||
noMoreResults: "نتیجه دیگری نیست.",
|
noMoreResults: "نتیجه دیگری نیست.",
|
||||||
@@ -33,6 +34,7 @@ export const fa = {
|
|||||||
sentCodeDesc: (mobile: string) => `کد ۶ رقمی به ${mobile} ارسال شد`,
|
sentCodeDesc: (mobile: string) => `کد ۶ رقمی به ${mobile} ارسال شد`,
|
||||||
mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)",
|
mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)",
|
||||||
continueWithPassword: "ادامه با رمز عبور",
|
continueWithPassword: "ادامه با رمز عبور",
|
||||||
|
continueWithGoogle: "ادامه با گوگل",
|
||||||
orContinueWith: "یا ادامه با",
|
orContinueWith: "یا ادامه با",
|
||||||
otpLogin: "ورود با کد یکبار مصرف",
|
otpLogin: "ورود با کد یکبار مصرف",
|
||||||
register: "ثبت نام",
|
register: "ثبت نام",
|
||||||
@@ -61,6 +63,26 @@ export const fa = {
|
|||||||
otpLoginMessage: (time: string) => `تلاش برای ورود با کد یکبار مصرف بیش از حد مجاز بوده است. ${time} دیگر دوباره تلاش کنید.`,
|
otpLoginMessage: (time: string) => `تلاش برای ورود با کد یکبار مصرف بیش از حد مجاز بوده است. ${time} دیگر دوباره تلاش کنید.`,
|
||||||
countdownLabel: (time: string) => `تلاش دوباره تا ${time}`,
|
countdownLabel: (time: string) => `تلاش دوباره تا ${time}`,
|
||||||
fallback: "درخواستهای زیادی ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.",
|
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 { AlertTriangle, ArrowLeft, ArrowRight, Command, Eye, EyeOff, Loader2 } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useTranslation } from "../hooks/useTranslation"
|
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 { ApiError } from "../api/client"
|
||||||
import { setSessionTokens } from "../lib/session"
|
import { setSessionTokens } from "../lib/session"
|
||||||
|
|
||||||
@@ -299,6 +299,16 @@ export default function Auth() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
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