From 0bae4e00ff07ee5cd4707f44f2ada90fdf634771 Mon Sep 17 00:00:00 2001 From: Methasit-Pun Date: Sat, 21 Feb 2026 20:28:40 +0700 Subject: [PATCH 1/4] fix: fix deploy issue bug and implement rate limit --- .gitattributes | 47 +++++ .gitignore | 14 ++ apps/backend/.env.example | 15 ++ apps/backend/src/index.ts | 26 ++- .../src/modules/agenda/agenda.route.ts | 8 + apps/backend/src/modules/auth/auth.route.ts | 8 + apps/backend/src/modules/event/event.route.ts | 8 + .../middleware/rate-limit.middleware.ts | 186 ++++++++++++++++++ apps/web/next.config.js | 34 ++++ apps/web/package.json | 2 + apps/web/src/app/event/page.tsx | 18 +- apps/web/src/config/link.ts | 4 +- apps/web/src/env.ts | 2 + .../src/hooks/mutations/use-auth-callback.ts | 21 +- apps/web/src/hooks/mutations/use-log-out.ts | 10 +- apps/web/src/hooks/use-rate-limit.ts | 181 +++++++++++++++++ apps/web/src/lib/auth.ts | 39 ++-- apps/web/src/lib/client.ts | 19 +- apps/web/src/lib/supabase.ts | 4 + .../modules/auth/callback/AuthCallback.tsx | 10 +- .../auth/callback/use-handle-auth-callback.ts | 5 +- .../auth/login/AuthenticatedUserLogin.tsx | 23 ++- .../src/modules/auth/login/LoginSection.tsx | 87 ++++++-- .../auth/login/UnauthenticatedUserLogin.tsx | 14 +- bun.lock | 33 +++- 25 files changed, 726 insertions(+), 92 deletions(-) create mode 100644 .gitattributes create mode 100644 apps/backend/.env.example create mode 100644 apps/backend/src/shared/middleware/rate-limit.middleware.ts create mode 100644 apps/web/src/hooks/use-rate-limit.ts create mode 100644 apps/web/src/lib/supabase.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5400e92 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,47 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# Explicitly declare text files you want to always be normalized and converted to native line endings on checkout +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.mjs text eol=lf +*.cjs text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf +*.css text eol=lf +*.scss text eol=lf +*.html text eol=lf +*.xml text eol=lf +*.svg text eol=lf +*.sh text eol=lf + +# Lock files should maintain LF +package-lock.json text eol=lf +bun.lockb binary +bun.lock text eol=lf +yarn.lock text eol=lf +pnpm-lock.yaml text eol=lf + +# Denote all files that are truly binary and should not be modified +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary +*.pdf binary +*.zip binary +*.gz binary + +# Windows-specific files +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf diff --git a/.gitignore b/.gitignore index 99bf5af..097f9be 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,17 @@ yarn-error.log* *storybook.log storybook-static + +# IDE +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.idea +*.swp +*.swo +*~ + +# OS +Thumbs.db diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 0000000..17887af --- /dev/null +++ b/apps/backend/.env.example @@ -0,0 +1,15 @@ +# Database Configuration +DATABASE_URL=postgresql://user:password@localhost:5432/eventer + +# Supabase Configuration +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_KEY=your-anon-key-here + +# Server Configuration +PORT=4000 +NODE_ENV=development + +# CORS Configuration +# In development: http://localhost:3000 +# In production: https://your-domain.com +CORS_ORIGIN=http://localhost:3000 diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index be6cbb5..d9839f3 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -25,13 +25,10 @@ const conditionalSwagger = () => { const app = new Elysia({ aot: false }) .use( cors({ - // origin: "https://eventer.betich.me", - origin: /.*\.betich\.me$/, - // [ - // /.*\.betich\.me$/, - // process.env.NODE_ENV === "development" ? /localhost:\d+/ : "", - // env.CORS_ORIGIN || "", - // ], + origin: + process.env.NODE_ENV === "production" + ? [/^https:\/\/.*\.betich\.me$/] // Only your domain in production + : [env.CORS_ORIGIN, /localhost:\d+/], // Allow localhost in dev credentials: true, preflight: true, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], @@ -43,9 +40,20 @@ const app = new Elysia({ aot: false }) .use(userRouter) .use(agendaRouter) .use(authRouter) - .onRequest(({ set }) => { + .onRequest(({ request, set }) => { set.headers["access-control-allow-credentials"] = "true"; - // set.headers["access-control-allow-origin"] = "https://eventer.betich.me"; + + // Force HTTPS in production + if (process.env.NODE_ENV === "production") { + const proto = request.headers.get("x-forwarded-proto"); + if (proto && proto !== "https") { + const url = new URL(request.url); + url.protocol = "https:"; + set.status = 301; + set.headers.location = url.toString(); + return; + } + } }); // .group("/api", (app) => // app diff --git a/apps/backend/src/modules/agenda/agenda.route.ts b/apps/backend/src/modules/agenda/agenda.route.ts index fb3f10b..6a97209 100644 --- a/apps/backend/src/modules/agenda/agenda.route.ts +++ b/apps/backend/src/modules/agenda/agenda.route.ts @@ -1,5 +1,6 @@ import { Elysia, t } from "elysia"; import { db } from "#backend/infrastructure/db"; +import { rateLimit, rateLimitPresets } from "#backend/shared/middleware/rate-limit.middleware"; import { AgendaListResponseSchema, AgendaSchema, @@ -24,6 +25,13 @@ const agendaRepository = new AgendaRepository(db); //TODO : Implement useGetAgenda(eventId, currentDay) export const agendaRouter = new Elysia({ prefix: "/api/agenda" }) + // Apply moderate rate limiting to agenda endpoints (30 requests per minute) + .use( + rateLimit({ + ...rateLimitPresets.moderate, + message: "Too many requests to agenda API. Please slow down.", + }) + ) .get( "/timer", diff --git a/apps/backend/src/modules/auth/auth.route.ts b/apps/backend/src/modules/auth/auth.route.ts index 1e49d63..0c14e98 100644 --- a/apps/backend/src/modules/auth/auth.route.ts +++ b/apps/backend/src/modules/auth/auth.route.ts @@ -2,6 +2,7 @@ import { Elysia } from "elysia"; import { db } from "#backend/infrastructure/db"; import { supabase } from "#backend/infrastructure/db/supabase"; import { UserRepository } from "#backend/modules/user/user.repository"; +import { rateLimit, rateLimitPresets } from "../../shared/middleware/rate-limit.middleware"; import { AuthCallbackSchema, AuthResponseSchema, @@ -13,6 +14,13 @@ import { const userRepository = new UserRepository(db); export const authRouter = new Elysia({ prefix: "/api/auth" }) + // Apply strict rate limiting to all auth endpoints (5 requests per minute) + .use( + rateLimit({ + ...rateLimitPresets.strict, + message: "Too many authentication attempts. Please try again later.", + }) + ) .get( "/session", async ({ cookie: { session } }) => { diff --git a/apps/backend/src/modules/event/event.route.ts b/apps/backend/src/modules/event/event.route.ts index e473779..4d500f0 100644 --- a/apps/backend/src/modules/event/event.route.ts +++ b/apps/backend/src/modules/event/event.route.ts @@ -1,6 +1,7 @@ import { Elysia } from "elysia"; import { db } from "#backend/infrastructure/db"; import { authMiddleware, optionalAuthMiddleware } from "#backend/shared/middleware/auth.middleware"; +import { rateLimit, rateLimitPresets } from "#backend/shared/middleware/rate-limit.middleware"; import { CreateEventSchema, EventListResponseSchema, @@ -13,6 +14,13 @@ import { createEvent, listEvents } from "./services/crud-event.service"; const eventRepository = new EventRepository(db); export const eventRouter = new Elysia({ prefix: "/api/event" }) + // Apply moderate rate limiting to event endpoints (30 requests per minute) + .use( + rateLimit({ + ...rateLimitPresets.moderate, + message: "Too many requests to event API. Please slow down.", + }) + ) .use(optionalAuthMiddleware) .get( "/", diff --git a/apps/backend/src/shared/middleware/rate-limit.middleware.ts b/apps/backend/src/shared/middleware/rate-limit.middleware.ts new file mode 100644 index 0000000..8c37a85 --- /dev/null +++ b/apps/backend/src/shared/middleware/rate-limit.middleware.ts @@ -0,0 +1,186 @@ +import { Elysia } from "elysia"; + +interface RateLimitConfig { + /** + * Maximum number of requests allowed within the duration window + */ + max: number; + + /** + * Time window in milliseconds + */ + duration: number; + + /** + * Optional custom key generator function + * Defaults to using IP address + */ + generator?: (context: { + request: Request; + headers: Record; + }) => string; + + /** + * Error message when rate limit is exceeded + */ + message?: string; + + /** + * Skip rate limiting based on custom logic + */ + skip?: (context: { request: Request }) => boolean; +} + +interface RateLimitEntry { + count: number; + resetTime: number; +} + +class RateLimiter { + private store: Map = new Map(); + private cleanupInterval: Timer; + + constructor() { + // Clean up expired entries every minute + this.cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of this.store.entries()) { + if (entry.resetTime < now) { + this.store.delete(key); + } + } + }, 60000); + } + + public check( + key: string, + max: number, + duration: number + ): { allowed: boolean; remaining: number; resetTime: number } { + const now = Date.now(); + const entry = this.store.get(key); + + // No existing entry or entry has expired + if (!entry || entry.resetTime < now) { + const resetTime = now + duration; + this.store.set(key, { count: 1, resetTime }); + return { + allowed: true, + remaining: max - 1, + resetTime, + }; + } + + // Increment count and check if limit exceeded + entry.count++; + this.store.set(key, entry); + + return { + allowed: entry.count <= max, + remaining: Math.max(0, max - entry.count), + resetTime: entry.resetTime, + }; + } + + public destroy() { + clearInterval(this.cleanupInterval); + this.store.clear(); + } +} + +// Global rate limiter instance +const rateLimiter = new RateLimiter(); + +/** + * Rate limiting middleware for Elysia + * + * @example + * ```ts + * app.use(rateLimit({ + * max: 5, // 5 requests + * duration: 60000, // per minute + * })) + * ``` + */ +export function rateLimit(config: RateLimitConfig) { + const { max, duration, generator, message, skip } = config; + + return new Elysia({ name: "rate-limit" }).onBeforeHandle(({ request, set, headers }) => { + // Skip rate limiting if custom skip function returns true + if (skip && skip({ request })) { + return; + } + + // Generate unique key for this client + const key = generator ? generator({ request, headers }) : getClientIdentifier(request, headers); + + // Check rate limit + const result = rateLimiter.check(key, max, duration); + + // Set rate limit headers + set.headers["X-RateLimit-Limit"] = max.toString(); + set.headers["X-RateLimit-Remaining"] = result.remaining.toString(); + set.headers["X-RateLimit-Reset"] = new Date(result.resetTime).toISOString(); + + // If limit exceeded, return 429 Too Many Requests + if (!result.allowed) { + set.status = 429; + const retryAfter = Math.ceil((result.resetTime - Date.now()) / 1000); + set.headers["Retry-After"] = retryAfter.toString(); + + throw new Error(message || `Too many requests. Please try again in ${retryAfter} seconds.`); + } + }); +} + +/** + * Get client identifier from request + * Tries to use IP address from various headers, falls back to random identifier + */ +function getClientIdentifier( + request: Request, + headers: Record +): string { + // Try to get IP from common headers + const forwardedFor = headers["x-forwarded-for"]; + if (forwardedFor) { + return forwardedFor.split(",")[0]?.trim() || "unknown"; + } + + const realIp = headers["x-real-ip"]; + if (realIp) { + return realIp; + } + + const cfConnectingIp = headers["cf-connecting-ip"]; + if (cfConnectingIp) { + return cfConnectingIp; + } + + // Fallback to URL+User-Agent combo if no IP available + const userAgent = headers["user-agent"] || "unknown"; + return `${request.url}-${userAgent}`; +} + +/** + * Predefined rate limit configurations + */ +export const rateLimitPresets = { + /** + * Strict rate limit for sensitive operations (auth, password reset, etc.) + * 5 requests per minute + */ + strict: { max: 5, duration: 60000 }, + + /** + * Moderate rate limit for API endpoints + * 30 requests per minute + */ + moderate: { max: 30, duration: 60000 }, + + /** + * Relaxed rate limit for public endpoints + * 100 requests per minute + */ + relaxed: { max: 100, duration: 60000 }, +}; diff --git a/apps/web/next.config.js b/apps/web/next.config.js index a795986..9cae0ad 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,5 +1,39 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + async headers() { + return [ + { + source: "/:path*", + headers: [ + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-XSS-Protection", + value: "1; mode=block", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + { + key: "Content-Security-Policy", + value: + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' https://lh3.googleusercontent.com data:; font-src 'self' data:; connect-src 'self' https://*.supabase.co http://localhost:*;", + }, + ], + }, + ]; + }, images: { remotePatterns: [ { diff --git a/apps/web/package.json b/apps/web/package.json index b9a3599..70f1ea3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,8 @@ }, "dependencies": { "@elysiajs/eden": "^1.3.2", + "@supabase/ssr": "^0.8.0", + "@supabase/supabase-js": "^2.97.0", "@t3-oss/env-nextjs": "^0.13.7", "@tailwindcss/postcss": "^4.1.11", "@tanstack/react-query": "^5.83.0", diff --git a/apps/web/src/app/event/page.tsx b/apps/web/src/app/event/page.tsx index c310e79..2d5e86b 100644 --- a/apps/web/src/app/event/page.tsx +++ b/apps/web/src/app/event/page.tsx @@ -7,7 +7,7 @@ import type React from "react"; import { useEffect, useState } from "react"; import { Button } from "@/components/atoms/button"; import { Input } from "@/components/atoms/input"; -import { getSession } from "@/lib/auth"; +import { useSession } from "@/hooks/use-session"; import AgendaSection from "../../modules/event/AgendaSection"; // Import all section components import OverviewSection from "../../modules/event/OverviewSection"; @@ -100,7 +100,9 @@ export default function EventManagementSPA() { getEventData(eventId) ); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const [user, setUser] = useState(null); + + // Use the client-side session hook instead of server action + const { user } = useSession(); // Navigation items - all ready for backend const navigationItems: NavigationItem[] = [ @@ -117,18 +119,6 @@ export default function EventManagementSPA() { setCurrentTime(new Date()); }, 1000); - // Fetch user session - const fetchUser = async () => { - try { - const sessionUser = await getSession(); - setUser(sessionUser); - } catch (error) { - console.error("Failed to fetch user session:", error); - } - }; - - fetchUser(); - return () => clearInterval(timer); }, []); diff --git a/apps/web/src/config/link.ts b/apps/web/src/config/link.ts index 5ab03c1..1e6a77b 100644 --- a/apps/web/src/config/link.ts +++ b/apps/web/src/config/link.ts @@ -18,9 +18,9 @@ const getAppUrl = () => { // Generate the sign-in link dynamically at runtime export const getSignInLink = () => { const redirectUri = encodeURIComponent(`${getAppUrl()}/auth/callback`); - return `https://qtrkroiyvtnwdpjscyvp.supabase.co/auth/v1/authorize?provider=google&redirect_to=${redirectUri}&scopes=email%20profile`; + return `${env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/authorize?provider=google&redirect_to=${redirectUri}&scopes=email%20profile`; }; // For backward compatibility, but this will use build-time URL const REDIRDCT_URI = encodeURIComponent(`${getAppUrl()}/auth/callback`); -export const SIGN_IN_LINK = `https://qtrkroiyvtnwdpjscyvp.supabase.co/auth/v1/authorize?provider=google&redirect_to=${REDIRDCT_URI}&scopes=email%20profile`; +export const SIGN_IN_LINK = `${env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/authorize?provider=google&redirect_to=${REDIRDCT_URI}&scopes=email%20profile`; diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index b8b09f5..b6548a2 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -16,6 +16,7 @@ export const env = createEnv({ client: { NEXT_PUBLIC_APP_URL: z.string().url().optional(), NEXT_PUBLIC_BACKEND_URL: z.string().url().optional(), + NEXT_PUBLIC_SUPABASE_URL: z.string().url(), }, /* * Due to how Next.js bundles environment variables on Edge and Client, @@ -26,5 +27,6 @@ export const env = createEnv({ runtimeEnv: { NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL, + NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL, }, }); diff --git a/apps/web/src/hooks/mutations/use-auth-callback.ts b/apps/web/src/hooks/mutations/use-auth-callback.ts index 7ffd074..b1e7809 100644 --- a/apps/web/src/hooks/mutations/use-auth-callback.ts +++ b/apps/web/src/hooks/mutations/use-auth-callback.ts @@ -1,19 +1,28 @@ import { useMutation } from "@tanstack/react-query"; -import { authHeaders } from "@/config/header"; import { client } from "@/lib/client"; export function useAuthCallbackMutation() { return useMutation({ mutationKey: ["auth-callback"], mutationFn: async (params: { access_token: string; refresh_token: string }) => { - const response = await client.api.auth.callback.post(params, { - headers: { - ...authHeaders, - }, + console.log("Sending auth callback with params:", { + access_token: params.access_token.substring(0, 20) + "...", + refresh_token: params.refresh_token.substring(0, 20) + "...", + }); + + const response = await client.api.auth.callback.post(params); + + console.log("Auth callback response:", { + status: response.status, + data: response.data, + error: response.error, }); if (response.status !== 200) { - throw new Error("Authentication failed"); + const errorMessage = response.error?.value + ? JSON.stringify(response.error.value) + : "Authentication failed"; + throw new Error(errorMessage); } return response.data; diff --git a/apps/web/src/hooks/mutations/use-log-out.ts b/apps/web/src/hooks/mutations/use-log-out.ts index 7ba281f..b56f369 100644 --- a/apps/web/src/hooks/mutations/use-log-out.ts +++ b/apps/web/src/hooks/mutations/use-log-out.ts @@ -1,7 +1,6 @@ "use client"; import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { authHeaders } from "@/config/header"; import { client } from "@/lib/client"; export function useLogOutMutation() { @@ -10,14 +9,7 @@ export function useLogOutMutation() { return useMutation({ mutationKey: ["logout"], mutationFn: async () => { - const response = await client.api.auth.logout.post( - {}, - { - headers: { - ...authHeaders, - }, - } - ); + const response = await client.api.auth.logout.post({}); if (response.status !== 200) { throw new Error("Logout failed"); diff --git a/apps/web/src/hooks/use-rate-limit.ts b/apps/web/src/hooks/use-rate-limit.ts new file mode 100644 index 0000000..b08c9ee --- /dev/null +++ b/apps/web/src/hooks/use-rate-limit.ts @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +interface RateLimitConfig { + /** + * Maximum number of allowed actions within the time window + */ + maxAttempts: number; + + /** + * Time window in milliseconds + */ + windowMs: number; + + /** + * Optional callback when rate limit is exceeded + */ + onLimitExceeded?: (remainingTime: number) => void; +} + +interface RateLimitState { + /** + * Whether an action is currently allowed + */ + isAllowed: boolean; + + /** + * Number of remaining attempts + */ + remaining: number; + + /** + * Time until the rate limit resets (in seconds) + */ + resetIn: number; + + /** + * Execute an action with rate limiting + * Returns true if the action was allowed, false if rate limited + */ + execute: (action: () => T | Promise) => Promise<{ success: boolean; result?: T }>; + + /** + * Reset the rate limiter + */ + reset: () => void; +} + +/** + * Hook for client-side rate limiting + * Prevents users from performing actions too frequently + * + * @example + * ```tsx + * const { isAllowed, remaining, resetIn, execute } = useRateLimit({ + * maxAttempts: 5, + * windowMs: 60000, // 1 minute + * onLimitExceeded: (resetIn) => { + * toast.error(`Too many attempts. Please wait ${resetIn} seconds.`); + * } + * }); + * + * const handleLogin = async () => { + * const { success, result } = await execute(() => loginApi()); + * if (!success) { + * console.log("Rate limited"); + * } + * }; + * ``` + */ +export function useRateLimit(config: RateLimitConfig): RateLimitState { + const { maxAttempts, windowMs, onLimitExceeded } = config; + + const [isAllowed, setIsAllowed] = useState(true); + const [remaining, setRemaining] = useState(maxAttempts); + const [resetIn, setResetIn] = useState(0); + + const attemptsRef = useRef([]); + const resetTimerRef = useRef(null); + + const updateState = useCallback(() => { + const now = Date.now(); + const windowStart = now - windowMs; + + // Remove attempts outside the current window + attemptsRef.current = attemptsRef.current.filter((timestamp) => timestamp > windowStart); + + const currentAttempts = attemptsRef.current.length; + const newRemaining = Math.max(0, maxAttempts - currentAttempts); + const allowed = currentAttempts < maxAttempts; + + setRemaining(newRemaining); + setIsAllowed(allowed); + + if (!allowed && attemptsRef.current.length > 0) { + const oldestAttempt = attemptsRef.current[0]; + if (oldestAttempt !== undefined) { + const resetTime = Math.ceil((oldestAttempt + windowMs - now) / 1000); + setResetIn(resetTime); + } + } else { + setResetIn(0); + } + }, [maxAttempts, windowMs]); + + const execute = useCallback( + async (action: () => T | Promise): Promise<{ success: boolean; result?: T }> => { + const now = Date.now(); + const windowStart = now - windowMs; + + // Clean up old attempts + attemptsRef.current = attemptsRef.current.filter((timestamp) => timestamp > windowStart); + + // Check if rate limit exceeded + if (attemptsRef.current.length >= maxAttempts) { + const oldestAttempt = attemptsRef.current[0]; + const resetTime = + oldestAttempt !== undefined ? Math.ceil((oldestAttempt + windowMs - now) / 1000) : 60; // Default to 60 seconds if undefined + + setIsAllowed(false); + setResetIn(resetTime); + setRemaining(0); + + if (onLimitExceeded) { + onLimitExceeded(resetTime); + } + + // Set up automatic state update when rate limit resets + if (resetTimerRef.current) { + clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = setTimeout(() => { + updateState(); + }, resetTime * 1000); + + return { success: false }; + } + + // Record this attempt + attemptsRef.current.push(now); + updateState(); + + // Execute the action + try { + const result = await Promise.resolve(action()); + return { success: true, result }; + } catch (error) { + throw error; + } + }, + [maxAttempts, windowMs, onLimitExceeded, updateState] + ); + + const reset = useCallback(() => { + attemptsRef.current = []; + setIsAllowed(true); + setRemaining(maxAttempts); + setResetIn(0); + + if (resetTimerRef.current) { + clearTimeout(resetTimerRef.current); + resetTimerRef.current = null; + } + }, [maxAttempts]); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (resetTimerRef.current) { + clearTimeout(resetTimerRef.current); + } + }; + }, []); + + return { + isAllowed, + remaining, + resetIn, + execute, + reset, + }; +} diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 5b45794..08b8b4d 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -1,28 +1,41 @@ "use server"; -import { authHeaders } from "@/config/header"; +import { cookies } from "next/headers"; import { env } from "@/env"; -import { client } from "./client"; export async function getSession() { - // If backend URL points to localhost, return null (not available in production) - if (!env.NEXT_PUBLIC_BACKEND_URL || env.NEXT_PUBLIC_BACKEND_URL.includes("localhost")) { - return null; - } - try { - const res = await client.api.auth.session.get({ + // Determine backend URL - support both development and production + const backendUrl = env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:4000"; + + // Get cookies from the request + const cookieStore = await cookies(); + const sessionCookie = cookieStore.get("session"); + + // If no session cookie, user is not authenticated + if (!sessionCookie) { + return null; + } + + // Call backend API with session cookie + const res = await fetch(`${backendUrl}/api/auth/session`, { + method: "GET", headers: { - ...authHeaders, + "Content-Type": "application/json", + Cookie: `session=${sessionCookie.value}`, }, + credentials: "include", + cache: "no-store", // Don't cache session checks }); - if (res.status !== 200) { + if (!res.ok) { return null; } - return res?.data?.user; - } catch { - // Silent error handling during build to prevent Vercel build failures + const data = await res.json(); + return data?.user || null; + } catch (error) { + // Silent error handling to prevent build/runtime failures + console.error("Session check failed:", error); return null; } } diff --git a/apps/web/src/lib/client.ts b/apps/web/src/lib/client.ts index ca0340f..c9f1ca4 100644 --- a/apps/web/src/lib/client.ts +++ b/apps/web/src/lib/client.ts @@ -21,15 +21,16 @@ const getBackendUrl = () => { }; export const client = treaty(getBackendUrl(), { - fetcher: (input, init) => - fetch(input, { + fetcher: (input, init) => { + // Let Eden Treaty handle Content-Type automatically + const headers = new Headers(init?.headers); + headers.set("X-Custom-Header", "betich"); + headers.set("Referrer-Policy", "origin-when-cross-origin"); + + return fetch(input, { ...init, credentials: "include", // ✅ Send cookies - headers: { - ...init?.headers, - "Content-Type": "application/json", // Ensure JSON content type - "X-Custom-Header": "betich", // Example of adding a custom header - "Referrer-Policy": "origin-when-cross-origin", // Set referrer policy - }, - }), + headers, + }); + }, }); diff --git a/apps/web/src/lib/supabase.ts b/apps/web/src/lib/supabase.ts new file mode 100644 index 0000000..f3e6ece --- /dev/null +++ b/apps/web/src/lib/supabase.ts @@ -0,0 +1,4 @@ +import { createClient } from "@supabase/supabase-js"; +import { env } from "@/env"; + +export const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_KEY); diff --git a/apps/web/src/modules/auth/callback/AuthCallback.tsx b/apps/web/src/modules/auth/callback/AuthCallback.tsx index a7ad1f1..17839a8 100644 --- a/apps/web/src/modules/auth/callback/AuthCallback.tsx +++ b/apps/web/src/modules/auth/callback/AuthCallback.tsx @@ -47,7 +47,7 @@ export function AuthCallback() { {userInfo && ( - Welcome! + Welcome Back! {userInfo.avatar_url && ( @@ -65,9 +65,11 @@ export function AuthCallback() { - - Redirecting to dashboard in a few seconds... - + + router.push("/")} className="w-full"> + Go to Home + + )} > diff --git a/apps/web/src/modules/auth/callback/use-handle-auth-callback.ts b/apps/web/src/modules/auth/callback/use-handle-auth-callback.ts index 1d51867..df281dd 100644 --- a/apps/web/src/modules/auth/callback/use-handle-auth-callback.ts +++ b/apps/web/src/modules/auth/callback/use-handle-auth-callback.ts @@ -51,9 +51,8 @@ export function useAuthCallback() { setUserInfo(res?.user ?? null); setMessage("Authentication successful! Redirecting..."); - setTimeout(() => { - router.push("/event"); - }, 500); + // Immediate redirect + router.push("/event"); }, onError: (error) => { setStatus("error"); diff --git a/apps/web/src/modules/auth/login/AuthenticatedUserLogin.tsx b/apps/web/src/modules/auth/login/AuthenticatedUserLogin.tsx index d4394b3..c475203 100644 --- a/apps/web/src/modules/auth/login/AuthenticatedUserLogin.tsx +++ b/apps/web/src/modules/auth/login/AuthenticatedUserLogin.tsx @@ -1,4 +1,7 @@ +"use client"; + import Image from "next/image"; +import { useRouter } from "next/navigation"; import { Button } from "@/components/atoms/button"; interface AuthenticatedUserLoginProps { @@ -7,6 +10,7 @@ interface AuthenticatedUserLoginProps { email: string; id: string; handleSignOut: () => void; + rateLimitMessage?: string; } export function AuthenticatedUserLogin({ @@ -15,7 +19,10 @@ export function AuthenticatedUserLogin({ email, id, handleSignOut, + rateLimitMessage, }: AuthenticatedUserLoginProps) { + const router = useRouter(); + return ( @@ -38,9 +45,19 @@ export function AuthenticatedUserLogin({ ID: {id} - - Sign Out - + {rateLimitMessage && ( + + ⚠️ {rateLimitMessage} + + )} + + router.push("/event")}> + Go to Events + + + Sign Out + + diff --git a/apps/web/src/modules/auth/login/LoginSection.tsx b/apps/web/src/modules/auth/login/LoginSection.tsx index 68f0736..5465b0f 100644 --- a/apps/web/src/modules/auth/login/LoginSection.tsx +++ b/apps/web/src/modules/auth/login/LoginSection.tsx @@ -1,10 +1,11 @@ "use client"; import { useRouter } from "next/navigation"; -import { useCallback } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Loading } from "@/components/organisms/loading/Loading"; import { authHeaders } from "@/config/header"; import { getSignInLink } from "@/config/link"; +import { useRateLimit } from "@/hooks/use-rate-limit"; import { useSession } from "@/hooks/use-session"; import { client } from "@/lib/client"; import { AuthenticatedUserLogin } from "./AuthenticatedUserLogin"; @@ -13,28 +14,72 @@ import { UnAuthenticatedUserLogin } from "./UnauthenticatedUserLogin"; export function LoginSection() { const { user, isLoading, refetch } = useSession(); const router = useRouter(); + const [rateLimitMessage, setRateLimitMessage] = useState(""); - const handleGoogleSignIn = useCallback(() => { - // Redirect to your backend's Google auth endpoint - router.push(getSignInLink()); - }, [router]); + // Rate limit: 5 login attempts per minute + const loginRateLimit = useRateLimit({ + maxAttempts: 5, + windowMs: 60000, // 1 minute + onLimitExceeded: (resetIn) => { + setRateLimitMessage( + `Too many login attempts. Please wait ${resetIn} seconds before trying again.` + ); + }, + }); - const handleSignOut = useCallback(() => { - client.api.auth.logout - .post( + // Rate limit: 3 logout attempts per minute + const logoutRateLimit = useRateLimit({ + maxAttempts: 3, + windowMs: 60000, // 1 minute + onLimitExceeded: (resetIn) => { + setRateLimitMessage( + `Too many logout attempts. Please wait ${resetIn} seconds before trying again.` + ); + }, + }); + + // Clear rate limit message after it's been shown + useEffect(() => { + if (rateLimitMessage) { + const timer = setTimeout(() => setRateLimitMessage(""), 5000); + return () => clearTimeout(timer); + } + }, [rateLimitMessage]); + + // Redirect authenticated users to event page + useEffect(() => { + if (!isLoading && user) { + router.push("/event"); + } + }, [user, isLoading, router]); + + const handleGoogleSignIn = useCallback(async () => { + const { success } = await loginRateLimit.execute(() => { + // Redirect to your backend's Google auth endpoint + router.push(getSignInLink()); + }); + + if (!success && rateLimitMessage) { + console.warn(rateLimitMessage); + } + }, [router, loginRateLimit, rateLimitMessage]); + + const handleSignOut = useCallback(async () => { + const { success } = await logoutRateLimit.execute(async () => { + await client.api.auth.logout.post( {}, { ...authHeaders, } - ) - .then(() => { - refetch(); - router.refresh(); - }) - .catch((error) => { - console.error("Logout failed:", error); - }); - }, [refetch, router]); + ); + refetch(); + router.refresh(); + }); + + if (!success && rateLimitMessage) { + console.warn(rateLimitMessage); + } + }, [refetch, router, logoutRateLimit, rateLimitMessage]); if (isLoading) { return ( @@ -52,9 +97,15 @@ export function LoginSection() { name={user.name} avatarUrl={user.avatar_url} handleSignOut={handleSignOut} + rateLimitMessage={rateLimitMessage} /> ); } - return ; + return ( + + ); } diff --git a/apps/web/src/modules/auth/login/UnauthenticatedUserLogin.tsx b/apps/web/src/modules/auth/login/UnauthenticatedUserLogin.tsx index 3106bda..3ddd27d 100644 --- a/apps/web/src/modules/auth/login/UnauthenticatedUserLogin.tsx +++ b/apps/web/src/modules/auth/login/UnauthenticatedUserLogin.tsx @@ -20,9 +20,13 @@ const inter = Inter({ interface UnAuthenticatedUserLoginProps { handleGoogleSignIn: () => void; + rateLimitMessage?: string; } -export function UnAuthenticatedUserLogin({ handleGoogleSignIn }: UnAuthenticatedUserLoginProps) { +export function UnAuthenticatedUserLogin({ + handleGoogleSignIn, + rateLimitMessage, +}: UnAuthenticatedUserLoginProps) { const [{ headerVisible, leftVisible, rightVisible }, setVisibility] = useState({ headerVisible: false, leftVisible: false, @@ -103,6 +107,14 @@ export function UnAuthenticatedUserLogin({ handleGoogleSignIn }: UnAuthenticated
- Redirecting to dashboard in a few seconds... -
ID: {id}
⚠️ {rateLimitMessage}
+ ⚠️ {rateLimitMessage} +
{n.message}
+ {n.time.toLocaleTimeString("th-TH")} +
{err}
{eventData.name}
Notes are saved locally in your browser.
+ Template auto-filled from event details. Edit the copied text as needed. +
+ Day {currentDay} — {currentDayData?.displayDate} +
No agenda for this day
Add agenda slots in the Agenda section first.
{slot.activity}
{slot.personincharge}
- {formatDate(eventData.startDate)} - {formatDate(eventData.endDate)} -
- {eventData.location} + {formatDateTH(eventData.startDate)} — {formatDateTH(eventData.endDate)}
{eventData.location}
{eventData.description}
+ {staffRoster.length} person{staffRoster.length !== 1 ? "s" : ""} derived from agenda —{" "} + {eventData.name} +
No staff found
Add agenda slots with a Person in Charge to populate this list.
{member.name}
+ {member.activities.length} activit + {member.activities.length !== 1 ? "ies" : "y"} +
{JSON.stringify(data, null, 2)}
กรอกข้อมูลเพื่อเริ่มจัดงาน
+ {event.description} +
- {n.time.toLocaleTimeString("th-TH")} -
จัดการและติดตามอีเวนต์ทั้งหมดของคุณ
{events.length}
ทั้งหมด
{counts.upcoming}
กำลังจะมา
{counts.ongoing}
กำลังจัด
+ {search || filter !== "all" + ? "ลองค้นหาด้วยคำอื่นหรือเปลี่ยนตัวกรอง" + : "กดปุ่มด้านล่างเพื่อสร้างอีเวนต์แรกของคุณ"} +
+ แสดง {filtered.length} จาก {events.length} อีเวนต์ +
+
ช่วยให้ทีมของคุณทำงานราบรื่น ตรงเวลา และมั่นใจในทุกช่วงของอีเวนต์
เราออกแบบโดยอิงพื้นฐานจาก Pain Point นักจัดอีเวนต์หลากๆ ที่
{item.description.split("\n").map((line, i) => ( {line} @@ -85,7 +85,7 @@ export const ShowcaseSection = () => { {/* Right: Image */} { alt={showcaseItems[activeIndex].title} width={400} height={400} - className="max-w-full max-h-[500px] h-auto rounded-xl scale-120 md:scale-100 md:mx-auto md:block" + className="max-w-full max-h-[300px] h-auto rounded-xl sm:max-h-[400px] lg:max-h-[500px]" priority /> )} diff --git a/apps/web/src/modules/home/TestimonialSection.tsx b/apps/web/src/modules/home/TestimonialSection.tsx index 951fe04..4953583 100644 --- a/apps/web/src/modules/home/TestimonialSection.tsx +++ b/apps/web/src/modules/home/TestimonialSection.tsx @@ -9,19 +9,26 @@ export const TestimonialSection = () => { "/cuslogo/thinc.png", ]; - // Duplicate logos for seamless infinite scroll effect - const scrollingLogos = [...logos, ...logos]; + // Repeat enough times so the strip always exceeds the viewport width on any screen + const scrollingLogos = [...logos, ...logos, ...logos, ...logos, ...logos, ...logos]; return ( - - + + พันธมิตรของเรา - - + + {scrollingLogos.map((logo, idx) => ( { alt={`Company logo ${(idx % logos.length) + 1}`} width={120} height={60} - className="max-w-[200px] max-h-[200px] w-auto h-[120px] object-contain hover:filter-none" + className="w-auto h-[60px] sm:h-[100px] max-w-[100px] sm:max-w-[160px] object-contain grayscale opacity-60 hover:grayscale-0 hover:opacity-100 transition-all duration-300" draggable={false} />