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

@@ -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 /> },
{

View File

@@ -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,
})

View File

@@ -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',

View File

@@ -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: {

View File

@@ -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: "شروع دوباره ورود با گوگل",
}
},

View File

@@ -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"

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>
);
}