From e635fd9c2c0bc81872419ddcdb7ea713869828ac Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Thu, 30 Apr 2026 15:25:45 +0330 Subject: [PATCH] feat(throttling): add global rate limit lockout flow --- src/App.tsx | 70 +-- src/api/client.ts | 132 ++++++ src/api/users.ts | 36 +- src/context/NotificationsContext.tsx | 7 +- src/context/WorkspaceContext.tsx | 20 +- src/lib/rateLimit.ts | 144 +++++++ src/locales/en.ts | 21 + src/locales/fa.ts | 21 + src/pages/Auth.tsx | 614 +++++++++++++++++---------- src/pages/RateLimit.tsx | 115 +++++ 10 files changed, 901 insertions(+), 279 deletions(-) create mode 100644 src/lib/rateLimit.ts create mode 100644 src/pages/RateLimit.tsx diff --git a/src/App.tsx b/src/App.tsx index b7326c2..ea48da3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { createBrowserRouter, RouterProvider, Navigate, Outlet } from "react-router-dom" +import { createBrowserRouter, RouterProvider, Navigate, Outlet, useLocation } from "react-router-dom" import { useState } from "react" import { ThemeProvider } from "./components/ThemeProvider" import { LanguageProvider } from "./components/LanguageProvider" @@ -23,6 +23,8 @@ import Reports from "./pages/Reports" import Timesheet from "./pages/Timesheet" import Logs from "./pages/Logs" import NotificationsPage from "./pages/Notifications" +import RateLimitPage from "./pages/RateLimit" +import { isRateLimitActive } from "./lib/rateLimit" const MainLayout = () => { const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) @@ -46,38 +48,58 @@ const MainLayout = () => { }; const RootRedirect = () => { + if (isRateLimitActive()) { + return + } + const isAuthenticated = !!localStorage.getItem("accessToken") return isAuthenticated ? : } +const RateLimitGuard = () => { + const location = useLocation() + + if (isRateLimitActive() && location.pathname !== "/rate-limit") { + return + } + + return +} + const router = createBrowserRouter([ { - element: ( - - - - ), + element: , children: [ - { path: "/", element: }, - { path: "/auth", element: }, - { path: "/terms", element: }, { - element: , + element: ( + + + + ), children: [ - { path: "/profile", element: }, - { path: "/timesheet", element: }, - { path: "/reports", element: }, - { path: "/notifications", element: }, - { path: "/logs", element: }, - { path: "/tags", element: }, - { path: "/workspaces", element: }, - { path: "/workspaces/create", element: }, - { path: "/workspaces/:id", element: }, - { path: "/workspaces/:id/edit", element: }, - { path: "/clients", element: }, - { path: "/projects", element: }, - { path: "/projects/create", element: }, - { path: "/projects/:id/edit", element: }, + { path: "/", element: }, + { path: "/auth", element: }, + { path: "/terms", element: }, + { path: "/rate-limit", element: }, + { + element: , + children: [ + { path: "/profile", element: }, + { path: "/timesheet", element: }, + { path: "/reports", element: }, + { path: "/notifications", element: }, + { path: "/logs", element: }, + { path: "/tags", element: }, + { path: "/workspaces", element: }, + { path: "/workspaces/create", element: }, + { path: "/workspaces/:id", element: }, + { path: "/workspaces/:id/edit", element: }, + { path: "/clients", element: }, + { path: "/projects", element: }, + { path: "/projects/create", element: }, + { path: "/projects/:id/edit", element: }, + ], + }, ], }, ], diff --git a/src/api/client.ts b/src/api/client.ts index 28c2bd7..b769268 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,10 @@ import { API_BASE_URL } from "../config/constants" +import { + activateRateLimitLock, + getRateLimitRemainingSeconds, + getStoredRateLimitLock, + isRateLimitActive, +} from "../lib/rateLimit" import { clearSessionTokens, emitSessionChanged, @@ -8,6 +14,42 @@ import { let refreshRequest: Promise | null = null +export interface ApiErrorMessage { + attr?: string | null + detail: string + code?: string | null +} + +export interface ApiErrorPayload { + error?: string + status_code?: number + messages?: ApiErrorMessage[] + code?: string + retry_after_seconds?: number | null + throttled_until?: string | null +} + +export class ApiError extends Error { + status: number + error: string + messages: ApiErrorMessage[] + code: string | null + retryAfterSeconds: number | null + throttledUntil: string | null + + constructor(status: number, payload: ApiErrorPayload, fallbackMessage: string) { + const detailMessage = payload.messages?.[0]?.detail + super(detailMessage || payload.error || fallbackMessage) + this.name = "ApiError" + this.status = status + this.error = payload.error || fallbackMessage + this.messages = payload.messages || [] + this.code = payload.code || null + this.retryAfterSeconds = payload.retry_after_seconds ?? null + this.throttledUntil = payload.throttled_until ?? null + } +} + const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "") const buildUrl = (endpoint: string) => { @@ -52,6 +94,50 @@ const clearSessionAndRedirect = () => { } } +const toIntOrNull = (value: string | number | null | undefined) => { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(0, Math.ceil(value)) + } + + if (typeof value === "string" && value.trim()) { + const parsed = Number.parseInt(value, 10) + if (Number.isFinite(parsed)) { + return Math.max(0, parsed) + } + } + + return null +} + +const redirectToRateLimitPage = () => { + if (window.location.pathname !== "/rate-limit") { + window.location.replace("/rate-limit") + } +} + +const createLockedResponse = () => { + const lock = getStoredRateLimitLock() + const retryAfterSeconds = getRateLimitRemainingSeconds(lock) + const payload = { + error: lock?.message || "Too many requests", + status_code: 429, + messages: [{ detail: lock?.message || "Too many requests" }], + code: lock?.code || "throttled", + retry_after_seconds: retryAfterSeconds, + throttled_until: lock?.throttledUntil || null, + } + + return normalizeJsonResponse( + new Response(JSON.stringify(payload), { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": String(retryAfterSeconds), + }, + }), + ) +} + const shouldAttemptRefresh = (endpoint: string) => { const normalizedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}` return ![ @@ -103,7 +189,34 @@ const refreshAccessToken = async () => { return refreshRequest } +export const buildApiError = async (response: Response) => { + let payload: ApiErrorPayload = {} + + try { + payload = await response.clone().json() + } catch { + payload = {} + } + + if (payload.retry_after_seconds == null) { + const retryAfter = toIntOrNull(response.headers.get("Retry-After")) + if (retryAfter != null) { + payload.retry_after_seconds = retryAfter + } + } + + const fallbackMessage = + response.statusText || (response.status === 429 ? "Too many requests" : "Request failed") + + return new ApiError(response.status, payload, fallbackMessage) +} + export const authFetch = async (endpoint: string, options: RequestInit = {}, allowRetry = true): Promise => { + if (isRateLimitActive()) { + redirectToRateLimitPage() + return createLockedResponse() + } + const token = getAccessToken() const isFormData = options.body instanceof FormData @@ -144,5 +257,24 @@ export const authFetch = async (endpoint: string, options: RequestInit = {}, all return response } + if (!response.ok) { + const apiError = await buildApiError(response) + if ( + response.status === 429 || + apiError.code === "throttled" || + apiError.retryAfterSeconds != null || + apiError.throttledUntil + ) { + activateRateLimitLock({ + status: response.status, + code: apiError.code, + message: apiError.message, + retryAfterSeconds: apiError.retryAfterSeconds, + throttledUntil: apiError.throttledUntil, + }) + redirectToRateLimitPage() + } + } + return normalizeJsonResponse(response) } diff --git a/src/api/users.ts b/src/api/users.ts index 921101b..6ba91b9 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -1,31 +1,31 @@ -import { authFetch } from './client'; +import { authFetch, buildApiError } from './client'; // --- Auth Endpoints --- -export const loginWithPassword = async (mobile: string, password: string) => { - const response = await authFetch('/api/users/login/', { - method: 'POST', - body: JSON.stringify({ mobile, password }) - }); - if (!response.ok) throw new Error('Failed to login with password'); - return response.json(); -}; +export const loginWithPassword = async (mobile: string, password: string) => { + const response = await authFetch('/api/users/login/', { + method: 'POST', + body: JSON.stringify({ mobile, password }) + }); + if (!response.ok) throw await buildApiError(response); + return response.json(); +}; -export const sendOtp = async (mobile: string, mode: string) => { - const response = await authFetch('/api/users/otp/send/', { - method: 'POST', - body: JSON.stringify({ mobile, mode }) - }); - if (!response.ok) throw new Error('Failed to send OTP'); - return response.json(); -}; +export const sendOtp = async (mobile: string, mode: string) => { + const response = await authFetch('/api/users/otp/send/', { + method: 'POST', + body: JSON.stringify({ mobile, mode }) + }); + if (!response.ok) throw await buildApiError(response); + return response.json(); +}; export const loginWithOtp = async (mobile: string, otp: string) => { const response = await authFetch('/api/users/otp/login/', { method: 'POST', body: JSON.stringify({ mobile, code: otp }) }); - if (!response.ok) throw new Error('Failed to login with OTP'); + if (!response.ok) throw await buildApiError(response); return response.json(); }; diff --git a/src/context/NotificationsContext.tsx b/src/context/NotificationsContext.tsx index e833a72..1d3cee1 100644 --- a/src/context/NotificationsContext.tsx +++ b/src/context/NotificationsContext.tsx @@ -23,6 +23,7 @@ import { } from "../api/notifications" import { useTranslation } from "../hooks/useTranslation" import { presentNotification } from "../lib/notificationPresenter" +import { isRateLimitActive } from "../lib/rateLimit" import { getAccessToken, SESSION_CHANGED_EVENT, @@ -171,7 +172,7 @@ export function NotificationsProvider({ children }: { children: ReactNode }) { ) const refreshNotifications = useCallback(async () => { - if (!getAccessToken()) { + if (!getAccessToken() || isRateLimitActive()) { setNotifications([]) setUnreadCount(0) setTotalCount(0) @@ -279,7 +280,7 @@ export function NotificationsProvider({ children }: { children: ReactNode }) { }, [markAsSeen, openNotificationTarget, t.notifications]) const connectToStream = useCallback(async () => { - if (!getAccessToken()) { + if (!getAccessToken() || isRateLimitActive()) { closeEventSource() setConnectionStatus("idle") return @@ -413,7 +414,7 @@ export function NotificationsProvider({ children }: { children: ReactNode }) { useEffect(() => { const startNotifications = async () => { - if (!getAccessToken()) { + if (!getAccessToken() || isRateLimitActive()) { closeEventSource() setNotifications([]) setUnreadCount(0) diff --git a/src/context/WorkspaceContext.tsx b/src/context/WorkspaceContext.tsx index b1c8b74..809915e 100644 --- a/src/context/WorkspaceContext.tsx +++ b/src/context/WorkspaceContext.tsx @@ -1,9 +1,10 @@ import { createContext, useContext, useState, useEffect, type ReactNode } from "react" -import { fetchWorkspaces, createWorkspace, type Workspace } from "../api/workspaces" -import { useTranslation } from "../hooks/useTranslation" -import { toast } from "sonner" -import { Button } from "../components/ui/button" -import { Input } from "../components/ui/input" +import { fetchWorkspaces, createWorkspace, type Workspace } from "../api/workspaces" +import { useTranslation } from "../hooks/useTranslation" +import { isRateLimitActive } from "../lib/rateLimit" +import { toast } from "sonner" +import { Button } from "../components/ui/button" +import { Input } from "../components/ui/input" interface WorkspaceContextType { workspaces: Workspace[] @@ -31,9 +32,10 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => { const [isCreatingFirst, setIsCreatingFirst] = useState(false) const isAuthenticated = !!localStorage.getItem("accessToken") + const rateLimited = isRateLimitActive() const refreshWorkspaces = async () => { - if (!isAuthenticated) { + if (!isAuthenticated || isRateLimitActive()) { setIsLoading(false) return } @@ -66,13 +68,13 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => { } useEffect(() => { - if (!isAuthenticated) { + if (!isAuthenticated || rateLimited) { setIsLoading(false) return } void refreshWorkspaces() - }, [isAuthenticated]) + }, [isAuthenticated, rateLimited]) const setActiveWorkspace = (workspace: Workspace | null) => { setActiveWorkspaceState(workspace) @@ -100,7 +102,7 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => { } // Force workspace creation if authenticated but none exist - if (!isLoading && isAuthenticated && workspaces.length === 0) { + if (!rateLimited && !isLoading && isAuthenticated && workspaces.length === 0) { return (
diff --git a/src/lib/rateLimit.ts b/src/lib/rateLimit.ts new file mode 100644 index 0000000..2bbbafd --- /dev/null +++ b/src/lib/rateLimit.ts @@ -0,0 +1,144 @@ +const STORAGE_KEY = "qlockify:rate-limit-lock" + +export interface RateLimitLock { + status: number + code: string | null + message: string + retryAfterSeconds: number + throttledUntil: string + returnTo: string +} + +interface ActivateRateLimitInput { + status: number + code?: string | null + message?: string | null + retryAfterSeconds?: number | null + throttledUntil?: string | null + returnTo?: string | null +} + +const DEFAULT_RETRY_SECONDS = 60 + +const isBrowser = typeof window !== "undefined" + +const getCurrentPath = () => { + if (!isBrowser) { + return "/" + } + + return `${window.location.pathname}${window.location.search}${window.location.hash}` +} + +const parseIsoDate = (value: string | null | undefined) => { + if (!value) { + return null + } + + const timestamp = Date.parse(value) + return Number.isFinite(timestamp) ? timestamp : null +} + +const readStoredLock = (): RateLimitLock | null => { + if (!isBrowser) { + return null + } + + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) { + return null + } + + const parsed = JSON.parse(raw) as Partial + if ( + typeof parsed.status !== "number" || + typeof parsed.message !== "string" || + typeof parsed.retryAfterSeconds !== "number" || + typeof parsed.throttledUntil !== "string" || + typeof parsed.returnTo !== "string" + ) { + window.localStorage.removeItem(STORAGE_KEY) + return null + } + + if (parseIsoDate(parsed.throttledUntil) == null) { + window.localStorage.removeItem(STORAGE_KEY) + return null + } + + return { + status: parsed.status, + code: typeof parsed.code === "string" ? parsed.code : null, + message: parsed.message, + retryAfterSeconds: parsed.retryAfterSeconds, + throttledUntil: parsed.throttledUntil, + returnTo: parsed.returnTo, + } + } catch { + window.localStorage.removeItem(STORAGE_KEY) + return null + } +} + +export const getRateLimitRemainingSeconds = (lock: RateLimitLock | null) => { + if (!lock) { + return 0 + } + + const untilTimestamp = parseIsoDate(lock.throttledUntil) + if (untilTimestamp == null) { + return 0 + } + + return Math.max(0, Math.ceil((untilTimestamp - Date.now()) / 1000)) +} + +export const getStoredRateLimitLock = () => readStoredLock() + +export const isRateLimitActive = () => getRateLimitRemainingSeconds(readStoredLock()) > 0 + +export const clearRateLimitLock = () => { + if (!isBrowser) { + return + } + + window.localStorage.removeItem(STORAGE_KEY) +} + +export const activateRateLimitLock = (input: ActivateRateLimitInput) => { + if (!isBrowser) { + return null + } + + const parsedThrottledUntil = parseIsoDate(input.throttledUntil) + const retryFromUntil = + parsedThrottledUntil != null + ? Math.ceil(Math.max(parsedThrottledUntil - Date.now(), 0) / 1000) + : 0 + const retryAfterSeconds = Math.max( + input.retryAfterSeconds ?? retryFromUntil ?? DEFAULT_RETRY_SECONDS, + retryFromUntil, + 0, + ) || DEFAULT_RETRY_SECONDS + + const throttledUntil = + input.throttledUntil && parsedThrottledUntil != null + ? input.throttledUntil + : new Date(Date.now() + retryAfterSeconds * 1000).toISOString() + + const returnTo = + input.returnTo && input.returnTo !== "/rate-limit" ? input.returnTo : getCurrentPath() + + const lock: RateLimitLock = { + status: input.status, + code: input.code ?? "throttled", + message: input.message || "Too many requests", + retryAfterSeconds, + throttledUntil, + returnTo: returnTo === "/rate-limit" ? "/" : returnTo, + } + + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(lock)) + return lock +} diff --git a/src/locales/en.ts b/src/locales/en.ts index 534af36..a3f9bdc 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -52,6 +52,15 @@ export const en = { invalidCreds: "Invalid credentials", enterOtp: "Please enter the OTP code", invalidOtp: "Invalid OTP code" + }, + 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.", } }, @@ -61,6 +70,18 @@ export const en = { suffix: "" }, + rateLimit: { + eyebrow: "Request limit reached", + title: "Please wait before trying again", + message: "You have sent too many requests. Access is temporarily locked until the cooldown finishes.", + cooldownLabel: "Cooldown", + waitingMessage: (time: string) => `Requests are blocked for now.`, + finishedMessage: "The cooldown has finished. You can continue now.", + continue: "Continue", + continueCooldown: (time: string) => `Continue in ${time}`, + ready: "Ready", + }, + terms: { back: "Back", title: "Terms of Service and Privacy Policy", diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 4590cf8..85025a6 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -52,6 +52,15 @@ export const fa = { invalidCreds: "اطلاعات ورود نامعتبر است", enterOtp: "لطفا کد تایید را وارد کنید", invalidOtp: "کد تایید نامعتبر است" + }, + throttle: { + title: "تعداد تلاش‌ها بیش از حد مجاز است", + genericMessage: (time: string) => `درخواست‌های زیادی ارسال شده است. ${time} دیگر دوباره تلاش کنید.`, + otpSendMessage: (time: string) => `ارسال کد یکبار مصرف بیش از حد مجاز انجام شده است. ${time} دیگر دوباره تلاش کنید.`, + passwordLoginMessage: (time: string) => `تلاش برای ورود با رمز عبور بیش از حد مجاز بوده است. ${time} دیگر دوباره تلاش کنید.`, + otpLoginMessage: (time: string) => `تلاش برای ورود با کد یکبار مصرف بیش از حد مجاز بوده است. ${time} دیگر دوباره تلاش کنید.`, + countdownLabel: (time: string) => `تلاش دوباره تا ${time}`, + fallback: "درخواست‌های زیادی ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.", } }, @@ -61,6 +70,18 @@ export const fa = { suffix: " ما موافقت می‌کنید." }, + rateLimit: { + eyebrow: "محدودیت درخواست فعال شده است", + title: "لطفاً پیش از تلاش دوباره صبر کنید", + message: "درخواست‌های زیادی ارسال شده است. دسترسی شما تا پایان زمان انتظار به صورت موقت محدود شده است.", + cooldownLabel: "زمان انتظار", + waitingMessage: (time: string) => `ارسال درخواست برای مدتی مسدود است.`, + finishedMessage: "زمان انتظار به پایان رسیده است. اکنون می‌توانید ادامه دهید.", + continue: "ادامه", + continueCooldown: (time: string) => `ادامه تا ${time}`, + ready: "آماده", + }, + terms: { back: "بازگشت", title: "شرایط خدمات و حریم خصوصی", diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx index ecafb7f..4f1b0af 100644 --- a/src/pages/Auth.tsx +++ b/src/pages/Auth.tsx @@ -1,233 +1,397 @@ -import React, { useState } from "react" -import { useNavigate, Link } from "react-router-dom" -import { Button } from "../components/ui/button" -import { Input } from "../components/ui/input" -import { SettingsMenu } from "../components/SettingsMenu" -import { ArrowLeft, ArrowRight, Command, Loader2, Eye, EyeOff } from "lucide-react" +import React, { useEffect, useMemo, useState } from "react" +import { useNavigate, Link } from "react-router-dom" +import { Button } from "../components/ui/button" +import { Input } from "../components/ui/input" +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 { loginWithPassword, sendOtp, loginWithOtp } from "../api/users" +import { loginWithOtp, loginWithPassword, sendOtp } from "../api/users" +import { ApiError } from "../api/client" import { setSessionTokens } from "../lib/session" - -type AuthStep = "mobile" | "password" | "otp" -type AuthMode = "login" | "register" - -export default function Auth() { - const navigate = useNavigate() - const { t, lang } = useTranslation() - const isRtl = lang === "fa" - - const [step, setStep] = useState("mobile") - const [mode, setMode] = useState("login") - const [mobile, setMobile] = useState("") - const [password, setPassword] = useState("") - const [otpCode, setOtpCode] = useState("") - const [loading, setLoading] = useState(false) - const [showPassword, setShowPassword] = useState(false) // Added state for password visibility - + +type AuthStep = "mobile" | "password" | "otp" +type AuthMode = "login" | "register" +type CooldownKey = "otpSend" | "passwordLogin" | "otpLogin" + +type Cooldowns = Record + +const PERSIAN_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"] + +const toPersianDigits = (value: string) => + value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number.parseInt(digit, 10)] ?? digit) + +export default function Auth() { + const navigate = useNavigate() + const { t, lang } = useTranslation() + const isRtl = lang === "fa" + + const [step, setStep] = useState("mobile") + const [mode, setMode] = useState("login") + const [mobile, setMobile] = useState("") + const [password, setPassword] = useState("") + const [otpCode, setOtpCode] = useState("") + const [loading, setLoading] = useState(false) + const [showPassword, setShowPassword] = useState(false) + const [cooldowns, setCooldowns] = useState({ + otpSend: 0, + passwordLogin: 0, + otpLogin: 0, + }) + + useEffect(() => { + if (!Object.values(cooldowns).some((value) => value > 0)) { + return + } + + const timer = window.setInterval(() => { + setCooldowns((current) => ({ + otpSend: Math.max(0, current.otpSend - 1), + passwordLogin: Math.max(0, current.passwordLogin - 1), + otpLogin: Math.max(0, current.otpLogin - 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 base = minutes > 0 + ? `${minutes}:${remainingSeconds.toString().padStart(2, "0")}` + : `${remainingSeconds}s` + + return localizeDigits(base) + } + + const setCooldown = (key: CooldownKey, seconds: number) => { + setCooldowns((current) => ({ + ...current, + [key]: Math.max(current[key], seconds), + })) + } + const handleTokenResponse = (access: string, refresh: string) => { setSessionTokens(access, refresh) toast.success(t.login.toasts.successLogin) navigate("/profile") } - - const handleSendOtp = async (selectedMode: AuthMode) => { - if (!mobile) return toast.error(t.login.toasts.enterMobile) - setLoading(true) - - try { - await sendOtp(mobile, selectedMode) - setMode(selectedMode) - setStep("otp") - toast.success(t.login.toasts.verifySent) - } catch (err: any) { - toast.error(t.login.toasts.failedOtp) - } finally { - setLoading(false) - } - } - - const handlePasswordLogin = async (e: React.FormEvent) => { - e.preventDefault() - if (!mobile || !password) return toast.error(t.login.toasts.fillAll) - setLoading(true) - - try { - const data = await loginWithPassword(mobile, password) - handleTokenResponse(data.access, data.refresh) - } catch (err: any) { - toast.error(t.login.toasts.invalidCreds) - } finally { - setLoading(false) - } - } - - const handleOtpVerify = async (e: React.FormEvent) => { - e.preventDefault() - if (!mobile || !otpCode) return toast.error(t.login.toasts.enterOtp) - setLoading(true) - - try { - const data = await loginWithOtp(mobile, otpCode) - handleTokenResponse(data.access, data.refresh) - } catch (err: any) { - toast.error(t.login.toasts.invalidOtp) - } finally { - setLoading(false) - } - } - - const BackIcon = isRtl ? ArrowRight : ArrowLeft - - return ( -
- -
- -
- -
-
- - {t.title || "Qlockify"} -
-
-
-

"{t.login.brandingQuote}"

-
-
-
- -
-
- -
-
- -
-

- {step === "mobile" && t.login.welcome(t.title)} - {step === "password" && t.login.enterPassword} - {step === "otp" && t.login.verifyNumber} -

-

- {step === "mobile" && t.login.enterMobileDesc} - {step === "password" && t.login.signInDesc} - {step === "otp" && t.login.sentCodeDesc(mobile)} -

-
- -
- {step === "mobile" && ( -
- setMobile(e.target.value)} - maxLength={11} - disabled={loading} - className={`h-11 ${isRtl ? "text-end" : "text-start"}`} - /> - - -
-
- -
-
- - {t.login.orContinueWith} - -
-
- -
- - -
-
- )} - - {step === "password" && ( -
-
- setPassword(e.target.value)} - disabled={loading} - className={`h-11 pr-10 ${isRtl ? "text-end" : "text-start"}`} - /> - -
- - -
- )} - - {step === "otp" && ( -
- setOtpCode(e.target.value)} - maxLength={6} - disabled={loading} - className="h-11 text-center tracking-widest text-lg" - /> - - -
- )} -
- -
- {t.loginTerms?.prefix} - - {t.loginTerms?.link} - - {t.loginTerms?.suffix} -
- -
-
-
- ) -} + + 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 throttleCopy = t.login.throttle + const message = + key === "otpSend" + ? throttleCopy.otpSendMessage(formattedTime) + : key === "passwordLogin" + ? throttleCopy.passwordLoginMessage(formattedTime) + : throttleCopy.otpLoginMessage(formattedTime) + + toast.error(message, { + description: throttleCopy.countdownLabel(formattedTime), + }) + + return true + } + + const handleSendOtp = async (selectedMode: AuthMode) => { + if (!mobile) { + toast.error(t.login.toasts.enterMobile) + return + } + + setLoading(true) + + try { + await sendOtp(mobile, selectedMode) + setCooldowns((current) => ({ ...current, otpSend: 0 })) + setMode(selectedMode) + setStep("otp") + toast.success(t.login.toasts.verifySent) + } catch (error) { + if (!handleThrottleError(error, "otpSend")) { + toast.error(error instanceof Error ? error.message : t.login.toasts.failedOtp) + } + } finally { + setLoading(false) + } + } + + const handlePasswordLogin = async (e: React.FormEvent) => { + e.preventDefault() + if (!mobile || !password) { + toast.error(t.login.toasts.fillAll) + return + } + + setLoading(true) + + try { + const data = await loginWithPassword(mobile, password) + setCooldowns((current) => ({ ...current, passwordLogin: 0 })) + handleTokenResponse(data.access, data.refresh) + } catch (error) { + if (!handleThrottleError(error, "passwordLogin")) { + toast.error(error instanceof Error ? error.message : t.login.toasts.invalidCreds) + } + } finally { + setLoading(false) + } + } + + const handleOtpVerify = async (e: React.FormEvent) => { + e.preventDefault() + if (!mobile || !otpCode) { + toast.error(t.login.toasts.enterOtp) + return + } + + setLoading(true) + + try { + const data = await loginWithOtp(mobile, otpCode) + setCooldowns((current) => ({ ...current, otpLogin: 0 })) + handleTokenResponse(data.access, data.refresh) + } catch (error) { + if (!handleThrottleError(error, "otpLogin")) { + toast.error(error instanceof Error ? error.message : t.login.toasts.invalidOtp) + } + } finally { + setLoading(false) + } + } + + const activeCooldownMessage = useMemo(() => { + const throttleCopy = t.login.throttle + + if (step === "mobile" && cooldowns.otpSend > 0) { + const formatted = formatCooldown(cooldowns.otpSend) + return { + title: throttleCopy.title, + description: throttleCopy.otpSendMessage(formatted), + } + } + + if (step === "password" && cooldowns.passwordLogin > 0) { + const formatted = formatCooldown(cooldowns.passwordLogin) + return { + title: throttleCopy.title, + description: throttleCopy.passwordLoginMessage(formatted), + } + } + + if (step === "otp" && cooldowns.otpLogin > 0) { + const formatted = formatCooldown(cooldowns.otpLogin) + return { + title: throttleCopy.title, + description: throttleCopy.otpLoginMessage(formatted), + } + } + + return null + }, [cooldowns, formatCooldown, step, t.login.throttle]) + + const otpCooldownLabel = + cooldowns.otpSend > 0 ? t.login.throttle.countdownLabel(formatCooldown(cooldowns.otpSend)) : null + const passwordCooldownLabel = + cooldowns.passwordLogin > 0 + ? t.login.throttle.countdownLabel(formatCooldown(cooldowns.passwordLogin)) + : null + const otpLoginCooldownLabel = + cooldowns.otpLogin > 0 ? t.login.throttle.countdownLabel(formatCooldown(cooldowns.otpLogin)) : null + + const BackIcon = isRtl ? ArrowRight : ArrowLeft + + return ( +
+
+ +
+ +
+
+ + {t.title || "Qlockify"} +
+
+
+

"{t.login.brandingQuote}"

+
+
+
+ +
+
+
+
+ +
+

+ {step === "mobile" && t.login.welcome(t.title)} + {step === "password" && t.login.enterPassword} + {step === "otp" && t.login.verifyNumber} +

+

+ {step === "mobile" && t.login.enterMobileDesc} + {step === "password" && t.login.signInDesc} + {step === "otp" && t.login.sentCodeDesc(mobile)} +

+
+ + {activeCooldownMessage && ( +
+
+ +
+

{activeCooldownMessage.title}

+

{activeCooldownMessage.description}

+
+
+
+ )} + +
+ {step === "mobile" && ( +
+ setMobile(e.target.value)} + maxLength={11} + disabled={loading} + className={`h-11 ${isRtl ? "text-end" : "text-start"}`} + /> + + +
+
+ +
+
+ + {t.login.orContinueWith} + +
+
+ +
+ + +
+
+ )} + + {step === "password" && ( +
+
+ setPassword(e.target.value)} + disabled={loading} + className={`h-11 pr-10 ${isRtl ? "text-end" : "text-start"}`} + /> + +
+ + +
+ )} + + {step === "otp" && ( +
+ setOtpCode(e.target.value)} + maxLength={6} + disabled={loading} + className="h-11 text-center tracking-widest text-lg" + /> + + +
+ )} +
+ +
+ {t.loginTerms?.prefix} + + {t.loginTerms?.link} + + {t.loginTerms?.suffix} +
+
+
+
+ ) +} diff --git a/src/pages/RateLimit.tsx b/src/pages/RateLimit.tsx new file mode 100644 index 0000000..c536c98 --- /dev/null +++ b/src/pages/RateLimit.tsx @@ -0,0 +1,115 @@ +import { useEffect, useMemo, useState } from "react" +import { useNavigate } from "react-router-dom" +import { AlertTriangle, Clock3 } from "lucide-react" +import { Button } from "../components/ui/button" +import { useTranslation } from "../hooks/useTranslation" +import { + clearRateLimitLock, + getRateLimitRemainingSeconds, + getStoredRateLimitLock, +} from "../lib/rateLimit" + +const PERSIAN_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"] + +const toPersianDigits = (value: string) => + value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number.parseInt(digit, 10)] ?? digit) + +export default function RateLimitPage() { + const navigate = useNavigate() + const { t, lang } = useTranslation() + const isRtl = lang === "fa" + + const initialLock = getStoredRateLimitLock() + const [returnTo] = useState(initialLock?.returnTo || "/") + const [status] = useState(initialLock?.status ?? 429) + const [message] = useState(initialLock?.message || t.rateLimit.message) + const [remainingSeconds, setRemainingSeconds] = useState(getRateLimitRemainingSeconds(initialLock)) + + useEffect(() => { + if (!initialLock) { + navigate(returnTo, { replace: true }) + return + } + + const timer = window.setInterval(() => { + const currentLock = getStoredRateLimitLock() + setRemainingSeconds(getRateLimitRemainingSeconds(currentLock)) + }, 1000) + + return () => window.clearInterval(timer) + }, [initialLock, navigate, returnTo]) + + const localizedDigits = (value: string) => (isRtl ? toPersianDigits(value) : value) + + const countdown = useMemo(() => { + const minutes = Math.floor(remainingSeconds / 60) + const seconds = remainingSeconds % 60 + const base = + minutes > 0 + ? `${minutes}:${seconds.toString().padStart(2, "0")}` + : `${seconds}s` + + return localizedDigits(base) + }, [isRtl, remainingSeconds]) + + const handleContinue = () => { + clearRateLimitLock() + navigate(returnTo, { replace: true }) + } + + const isCoolingDown = remainingSeconds > 0 + + return ( +
+
+
+
+
+ +
+
+ +
+

+ {t.rateLimit.eyebrow} +

+

+ {t.rateLimit.title} +

+

+ {t.rateLimit.message} +

+
+ +
+
+
+ +

+ {t.rateLimit.cooldownLabel} +

+
+

+ {isCoolingDown ? countdown : t.rateLimit.ready} +

+
+
+ +
+ {isCoolingDown ? t.rateLimit.waitingMessage(countdown) : t.rateLimit.finishedMessage} +
+ +
+ +
+
+
+
+ ) +}