From b688bb1ec3b60722ce9a13acf78b4b421b9d5d54 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 1 May 2026 01:54:26 +0330 Subject: [PATCH] feat(auth): add google sign-in onboarding flow --- src/App.tsx | 2 + src/api/client.ts | 6 +- src/api/users.ts | 70 +++++- src/locales/en.ts | 46 +++- src/locales/fa.ts | 40 +++- src/pages/Auth.tsx | 12 +- src/pages/GoogleAuthCallback.tsx | 392 +++++++++++++++++++++++++++++++ 7 files changed, 540 insertions(+), 28 deletions(-) create mode 100644 src/pages/GoogleAuthCallback.tsx diff --git a/src/App.tsx b/src/App.tsx index 17d49a8..f873901 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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: }, { path: "/app", element: }, { path: "/auth", element: }, + { path: "/auth/google/callback", element: }, { path: "/terms", element: }, { path: "/rate-limit", element: }, { diff --git a/src/api/client.ts b/src/api/client.ts index b769268..cde271f 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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, }) diff --git a/src/api/users.ts b/src/api/users.ts index 6ba91b9..b6fc06a 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -1,4 +1,4 @@ -import { authFetch, buildApiError } from './client'; +import { authFetch, buildApiError, buildApiUrl } from './client'; // --- Auth Endpoints --- @@ -28,8 +28,72 @@ export const loginWithOtp = async (mobile: string, otp: string) => { if (!response.ok) throw await buildApiError(response); return response.json(); }; - -export const logoutUser = async (refreshToken: string) => { + +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 => { + 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 => { + 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 => { + 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', body: JSON.stringify({ refresh: refreshToken }) diff --git a/src/locales/en.ts b/src/locales/en.ts index 256ba8c..b96cecc 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -6,10 +6,11 @@ export const en = { confirmLogoutMessage: "Are you sure you want to log out of your account?", confirmLeave: "You have unsaved changes. Are you sure you want to leave?", cancel: "Cancel", - save: "Save", - lightMode: "Light Mode", - darkMode: "Dark Mode", - loadingText: "Loading...", + save: "Save", + lightMode: "Light Mode", + darkMode: "Dark Mode", + settings: "Settings", + loadingText: "Loading...", loading: "Loading...", add: "Add", create: "Create", @@ -31,9 +32,10 @@ export const en = { enterMobileDesc: "Enter your mobile number to continue", signInDesc: "Sign in using your account password", sentCodeDesc: (mobile: string) => `We sent a 6-digit code to ${mobile}`, - mobilePlaceholder: "Mobile Number (e.g. 09123456789)", - continueWithPassword: "Continue with Password", - orContinueWith: "Or continue with", + mobilePlaceholder: "Mobile Number (e.g. 09123456789)", + continueWithPassword: "Continue with Password", + continueWithGoogle: "Continue with Google", + orContinueWith: "Or continue with", otpLogin: "OTP Login", register: "Register", passwordPlaceholder: "Password", @@ -53,16 +55,36 @@ export const en = { enterOtp: "Please enter the OTP code", invalidOtp: "Invalid OTP code" }, - throttle: { - title: "Too many attempts", + throttle: { + title: "Too many attempts", genericMessage: (time: string) => `Too many requests. Try again in ${time}.`, otpSendMessage: (time: string) => `Too many OTP requests. Try again in ${time}.`, passwordLoginMessage: (time: string) => `Too many password login attempts. Try again in ${time}.`, 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.", - } - }, + 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: { prefix: "By logging in, you agree to our ", diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 9648716..e7500de 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -11,7 +11,8 @@ export const fa = { save: "ذخیره", remove: "حذف", lightMode: "حالت روشن", - darkMode: "حالت تاریک", + darkMode: "حالت تاریک", + settings: "تنظیمات", loadingText: "در حال بارگذاری...", loading: "در حال بارگذاری...", noMoreResults: "نتیجه دیگری نیست.", @@ -31,9 +32,10 @@ export const fa = { enterMobileDesc: "برای ادامه، شماره موبایل خود را وارد کنید", signInDesc: "با استفاده از رمز عبور خود وارد شوید", sentCodeDesc: (mobile: string) => `کد ۶ رقمی به ${mobile} ارسال شد`, - mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)", - continueWithPassword: "ادامه با رمز عبور", - orContinueWith: "یا ادامه با", + mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)", + continueWithPassword: "ادامه با رمز عبور", + continueWithGoogle: "ادامه با گوگل", + orContinueWith: "یا ادامه با", otpLogin: "ورود با کد یکبار مصرف", register: "ثبت نام", passwordPlaceholder: "رمز عبور", @@ -53,16 +55,36 @@ export const fa = { enterOtp: "لطفا کد تایید را وارد کنید", invalidOtp: "کد تایید نامعتبر است" }, - throttle: { - title: "تعداد تلاش‌ها بیش از حد مجاز است", + throttle: { + title: "تعداد تلاش‌ها بیش از حد مجاز است", genericMessage: (time: string) => `درخواست‌های زیادی ارسال شده است. ${time} دیگر دوباره تلاش کنید.`, otpSendMessage: (time: string) => `ارسال کد یکبار مصرف بیش از حد مجاز انجام شده است. ${time} دیگر دوباره تلاش کنید.`, passwordLoginMessage: (time: string) => `تلاش برای ورود با رمز عبور بیش از حد مجاز بوده است. ${time} دیگر دوباره تلاش کنید.`, otpLoginMessage: (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: "شروع دوباره ورود با گوگل", + } + }, loginTerms: { prefix: "با ورود به سیستم، شما با ", diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx index 4f1b0af..8221e3e 100644 --- a/src/pages/Auth.tsx +++ b/src/pages/Auth.tsx @@ -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() { + +
+ + + )} + + {step === "claim_required" && ( +
+
+

+ {t.login.google.claimDescription(mobile)} +

+ setOtpCode(event.target.value)} + maxLength={6} + disabled={loading} + className="h-11 text-center text-lg tracking-widest" + /> +
+ + + +
+ )} + + {step === "error" && ( +
+ +

+ {errorMessage || t.login.google.loadFailed} +

+
+ + +
+
+ )} +
+ + + + ); +}