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... -

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

- + {rateLimitMessage && ( +
+

⚠️ {rateLimitMessage}

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

+ {rateLimitMessage && ( +
+

+ ⚠️ {rateLimitMessage} +

+
+ )} + + + {/* Three-dot menu */} +
+ + + {isUserMenuOpen && ( +
+ +
+ )} +
diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index b6548a2..ff4385c 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -17,6 +17,7 @@ export const env = createEnv({ NEXT_PUBLIC_APP_URL: z.string().url().optional(), NEXT_PUBLIC_BACKEND_URL: z.string().url().optional(), NEXT_PUBLIC_SUPABASE_URL: z.string().url(), + NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1), }, /* * Due to how Next.js bundles environment variables on Edge and Client, @@ -28,5 +29,6 @@ export const env = createEnv({ 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, + NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, }, }); diff --git a/apps/web/src/hooks/use-rate-limit.test.ts b/apps/web/src/hooks/use-rate-limit.test.ts new file mode 100644 index 0000000..b024b3c --- /dev/null +++ b/apps/web/src/hooks/use-rate-limit.test.ts @@ -0,0 +1,289 @@ +/** + * Unit tests for useRateLimit hook + * + * Covers: + * - Allows actions under the limit + * - Blocks actions once the limit is reached + * - Correct remaining / resetIn state updates + * - onLimitExceeded callback is invoked with the correct remaining time + * - State resets automatically after the window expires + * - Manual reset via the returned reset() function + * - Cleanup: no state updates after the component unmounts + */ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useRateLimit } from "./use-rate-limit"; + +describe("useRateLimit", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + // ── Allowing actions ─────────────────────────────────────────────────── + + it("allows the first action and decrements remaining by 1", async () => { + const { result } = renderHook(() => useRateLimit({ maxAttempts: 3, windowMs: 60_000 })); + + expect(result.current.isAllowed).toBe(true); + expect(result.current.remaining).toBe(3); + + await act(async () => { + await result.current.execute(() => "ok"); + }); + + expect(result.current.remaining).toBe(2); + expect(result.current.isAllowed).toBe(true); + }); + + it("returns { success: true, result } for allowed actions", async () => { + const { result } = renderHook(() => useRateLimit({ maxAttempts: 5, windowMs: 60_000 })); + + let outcome: { success: boolean; result?: string } | undefined; + + await act(async () => { + outcome = await result.current.execute(() => "action-result"); + }); + + expect(outcome).toEqual({ success: true, result: "action-result" }); + }); + + it("executes all attempts up to the limit", async () => { + const maxAttempts = 3; + const { result } = renderHook(() => useRateLimit({ maxAttempts, windowMs: 60_000 })); + + for (let i = 0; i < maxAttempts; i++) { + // eslint-disable-next-line no-await-in-loop + await act(async () => { + const outcome = await result.current.execute(() => "ok"); + expect(outcome.success).toBe(true); + }); + } + + expect(result.current.remaining).toBe(0); + }); + + // ── Blocking actions ─────────────────────────────────────────────────── + + it("blocks the action once the limit is exceeded and returns { success: false }", async () => { + const { result } = renderHook(() => useRateLimit({ maxAttempts: 2, windowMs: 60_000 })); + + // Exhaust the limit + await act(async () => { + await result.current.execute(() => "first"); + await result.current.execute(() => "second"); + }); + + let outcome: { success: boolean; result?: string } | undefined; + + await act(async () => { + outcome = await result.current.execute(() => "should-be-blocked"); + }); + + expect(outcome?.success).toBe(false); + expect(outcome?.result).toBeUndefined(); + }); + + it("sets isAllowed to false and remaining to 0 when rate limited", async () => { + const { result } = renderHook(() => useRateLimit({ maxAttempts: 1, windowMs: 60_000 })); + + await act(async () => { + await result.current.execute(() => "first"); // consumes the only slot + }); + + await act(async () => { + await result.current.execute(() => "blocked"); + }); + + expect(result.current.isAllowed).toBe(false); + expect(result.current.remaining).toBe(0); + }); + + it("does not execute the blocked action's callback", async () => { + const action = vi.fn(); + + const { result } = renderHook(() => useRateLimit({ maxAttempts: 1, windowMs: 60_000 })); + + await act(async () => { + await result.current.execute(action); // allowed + }); + + await act(async () => { + await result.current.execute(action); // blocked + }); + + expect(action).toHaveBeenCalledTimes(1); + }); + + // ── onLimitExceeded callback ─────────────────────────────────────────── + + it("calls onLimitExceeded with a positive resetIn seconds value when blocked", async () => { + const onLimitExceeded = vi.fn(); + + const { result } = renderHook(() => + useRateLimit({ maxAttempts: 1, windowMs: 30_000, onLimitExceeded }) + ); + + // Use first attempt + await act(async () => { + await result.current.execute(() => "ok"); + }); + + // Trigger rate limit + await act(async () => { + await result.current.execute(() => "blocked"); + }); + + expect(onLimitExceeded).toHaveBeenCalledOnce(); + const [resetIn] = onLimitExceeded.mock.calls[0] as [number]; + expect(resetIn).toBeGreaterThan(0); + expect(resetIn).toBeLessThanOrEqual(30); // window is 30 s + }); + + it("provides a resetIn value (in seconds) that reflects the remaining window time", async () => { + const onLimitExceeded = vi.fn(); + + const { result } = renderHook(() => + useRateLimit({ maxAttempts: 1, windowMs: 60_000, onLimitExceeded }) + ); + + await act(async () => { + await result.current.execute(() => "ok"); + }); + + // Advance 20 seconds into the window + act(() => { + vi.advanceTimersByTime(20_000); + }); + + await act(async () => { + await result.current.execute(() => "blocked"); + }); + + const [resetIn] = onLimitExceeded.mock.calls[0] as [number]; + // ~40 s remain; allow ±2 s for implementation rounding + expect(resetIn).toBeGreaterThanOrEqual(38); + expect(resetIn).toBeLessThanOrEqual(41); + }); + + // ── Auto-reset after the window ──────────────────────────────────────── + + it("allows actions again after the rate-limit window expires", async () => { + const windowMs = 5_000; + + const { result } = renderHook(() => useRateLimit({ maxAttempts: 1, windowMs })); + + // Exhaust the limit + await act(async () => { + await result.current.execute(() => "first"); + }); + + await act(async () => { + await result.current.execute(() => "blocked"); + }); + + expect(result.current.isAllowed).toBe(false); + + // Advance past the window + the resetTimerRef timeout + act(() => { + vi.advanceTimersByTime(windowMs + 100); + }); + + // State is updated by the internal timer; trigger an execute to confirm + await act(async () => { + const outcome = await result.current.execute(() => "allowed-again"); + expect(outcome.success).toBe(true); + }); + }); + + // ── Manual reset ─────────────────────────────────────────────────────── + + it("restores full remaining and isAllowed=true after calling reset()", async () => { + const maxAttempts = 3; + const { result } = renderHook(() => useRateLimit({ maxAttempts, windowMs: 60_000 })); + + // Use all attempts + await act(async () => { + for (let i = 0; i < maxAttempts; i++) { + await result.current.execute(() => "ok"); + } + }); + + expect(result.current.remaining).toBe(0); + + act(() => { + result.current.reset(); + }); + + expect(result.current.isAllowed).toBe(true); + expect(result.current.remaining).toBe(maxAttempts); + expect(result.current.resetIn).toBe(0); + }); + + it("allows a new action immediately after manual reset even if the window has not expired", async () => { + const { result } = renderHook(() => useRateLimit({ maxAttempts: 1, windowMs: 60_000 })); + + await act(async () => { + await result.current.execute(() => "first"); // consumes slot + }); + + act(() => { + result.current.reset(); + }); + + await act(async () => { + const outcome = await result.current.execute(() => "post-reset"); + expect(outcome.success).toBe(true); + }); + }); + + // ── Edge cases ───────────────────────────────────────────────────────── + + it("propagates exceptions thrown by the action function", async () => { + const { result } = renderHook(() => useRateLimit({ maxAttempts: 5, windowMs: 60_000 })); + + await expect( + act(async () => { + await result.current.execute(() => { + throw new Error("Action failed"); + }); + }) + ).rejects.toThrow("Action failed"); + }); + + it("handles async action functions correctly", async () => { + const { result } = renderHook(() => useRateLimit({ maxAttempts: 5, windowMs: 60_000 })); + + const asyncAction = vi.fn().mockResolvedValue("async-result"); + + let outcome: { success: boolean; result?: string } | undefined; + + await act(async () => { + outcome = await result.current.execute(asyncAction); + }); + + expect(outcome).toEqual({ success: true, result: "async-result" }); + expect(asyncAction).toHaveBeenCalledOnce(); + }); + + it("tracks attempts correctly across multiple rapid consecutive calls", async () => { + const maxAttempts = 3; + const { result } = renderHook(() => useRateLimit({ maxAttempts, windowMs: 60_000 })); + + const results: boolean[] = []; + + await act(async () => { + for (let i = 0; i < 5; i++) { + const { success } = await result.current.execute(() => "ok"); + results.push(success); + } + }); + + // First 3 succeed, next 2 are blocked + expect(results).toEqual([true, true, true, false, false]); + }); +}); diff --git a/apps/web/src/hooks/use-session.ts b/apps/web/src/hooks/use-session.ts index 6fb36fa..128e11b 100644 --- a/apps/web/src/hooks/use-session.ts +++ b/apps/web/src/hooks/use-session.ts @@ -1,31 +1,61 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { authHeaders } from "@/config/header"; -import { client } from "@/lib/client"; +import type { User } from "@supabase/supabase-js"; +import { useCallback, useEffect, useState } from "react"; +import { createClient } from "@/lib/supabase"; + +interface UserInfo { + id: string; + email: string; + name: string; + avatar_url?: string; +} + +function mapUser(supabaseUser: User | null): UserInfo | null { + if (!supabaseUser) return null; + return { + id: supabaseUser.id, + email: supabaseUser.email ?? "", + name: + supabaseUser.user_metadata.full_name ?? + supabaseUser.user_metadata.name ?? + supabaseUser.email ?? + "", + avatar_url: + supabaseUser.user_metadata.avatar_url ?? supabaseUser.user_metadata.picture ?? undefined, + }; +} export function useSession() { - const { - data: user, - isLoading, - error, - refetch, - } = useQuery({ - queryKey: ["session"], - queryFn: async () => { - const response = await client.api.auth.session.get({ - headers: { - ...authHeaders, - }, - }); - - if (!(response.status === 200)) { - throw new Error("Failed to fetch session"); - } - - return response?.data?.user; - }, - }); - - return { user, isLoading, error, refetch }; + const [user, setUser] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + + const supabase = createClient(); + + const refetch = useCallback(async () => { + const { + data: { user: supabaseUser }, + } = await supabase.auth.getUser(); + setUser(mapUser(supabaseUser)); + }, [supabase]); + + useEffect(() => { + // Get the initial session on mount + supabase.auth.getUser().then(({ data: { user: supabaseUser } }) => { + setUser(mapUser(supabaseUser)); + setIsLoading(false); + }); + + // Keep state in sync with Supabase auth events (sign-in, sign-out, token refresh) + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, session) => { + setUser(mapUser(session?.user ?? null)); + setIsLoading(false); + }); + + return () => subscription.unsubscribe(); + }, [supabase]); + + return { user, isLoading, refetch }; } diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 08b8b4d..3d3fc06 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -1,40 +1,25 @@ "use server"; -import { cookies } from "next/headers"; -import { env } from "@/env"; +import { createClient } from "@/lib/supabase-server"; export async function getSession() { try { - // Determine backend URL - support both development and production - const backendUrl = env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:4000"; + const supabase = await createClient(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); - // 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: { - "Content-Type": "application/json", - Cookie: `session=${sessionCookie.value}`, - }, - credentials: "include", - cache: "no-store", // Don't cache session checks - }); - - if (!res.ok) { + if (error || !user) { return null; } - const data = await res.json(); - return data?.user || null; + return { + id: user.id, + email: user.email ?? "", + name: user.user_metadata.full_name ?? user.user_metadata.name ?? user.email ?? "", + avatar_url: user.user_metadata.avatar_url ?? user.user_metadata.picture ?? undefined, + }; } 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/supabase-middleware.ts b/apps/web/src/lib/supabase-middleware.ts new file mode 100644 index 0000000..ea71a50 --- /dev/null +++ b/apps/web/src/lib/supabase-middleware.ts @@ -0,0 +1,55 @@ +import { createServerClient } from "@supabase/ssr"; +import { type NextRequest, NextResponse } from "next/server"; +import { env } from "@/env"; + +export async function updateSession(request: NextRequest) { + let supabaseResponse = NextResponse.next({ + request, + }); + + const supabase = createServerClient( + env.NEXT_PUBLIC_SUPABASE_URL, + env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + for (const { name, value } of cookiesToSet) { + request.cookies.set(name, value); + } + supabaseResponse = NextResponse.next({ + request, + }); + for (const { name, value, options } of cookiesToSet) { + supabaseResponse.cookies.set(name, value, options); + } + }, + }, + } + ); + + // IMPORTANT: Avoid writing any logic between createServerClient and + // supabase.auth.getUser(). A simple mistake could make it very hard to debug + // issues with users being randomly logged out. + + const { + data: { user }, + } = await supabase.auth.getUser(); + + // IMPORTANT: You *must* return the supabaseResponse object as it is. If you're + // creating a new response object with NextResponse.next() make sure to: + // 1. Pass the request in it, like so: + // const myNewResponse = NextResponse.next({ request }) + // 2. Copy over the cookies, like so: + // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll()) + // 3. Change the myNewResponse object to fit your needs, but avoid changing + // the cookies! + // 4. Finally: + // return myNewResponse + // If this is not done, you may be causing the browser and server to go out + // of sync and terminate the user's session prematurely! + + return supabaseResponse; +} diff --git a/apps/web/src/lib/supabase-server.ts b/apps/web/src/lib/supabase-server.ts new file mode 100644 index 0000000..1d0946a --- /dev/null +++ b/apps/web/src/lib/supabase-server.ts @@ -0,0 +1,26 @@ +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import { env } from "@/env"; + +export async function createClient() { + const cookieStore = await cookies(); + + return createServerClient(env.NEXT_PUBLIC_SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_ANON_KEY, { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + for (const { name, value, options } of cookiesToSet) { + cookieStore.set(name, value, options); + } + } catch (error) { + // The `setAll` method was called from a Server Component. + // This can be ignored if you have middleware refreshing + // user sessions. + } + }, + }, + }); +} diff --git a/apps/web/src/lib/supabase.ts b/apps/web/src/lib/supabase.ts index f3e6ece..8641ba6 100644 --- a/apps/web/src/lib/supabase.ts +++ b/apps/web/src/lib/supabase.ts @@ -1,4 +1,6 @@ -import { createClient } from "@supabase/supabase-js"; +import { createBrowserClient } from "@supabase/ssr"; import { env } from "@/env"; -export const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_KEY); +export function createClient() { + return createBrowserClient(env.NEXT_PUBLIC_SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_ANON_KEY); +} diff --git a/apps/web/src/modules/auth/callback/use-handle-auth-callback.test.ts b/apps/web/src/modules/auth/callback/use-handle-auth-callback.test.ts new file mode 100644 index 0000000..695a033 --- /dev/null +++ b/apps/web/src/modules/auth/callback/use-handle-auth-callback.test.ts @@ -0,0 +1,260 @@ +/** + * Unit tests for useAuthCallback hook + * + * Covers: + * - No tokens in URL hash → immediate redirect to /auth/login + * - Valid tokens → submits to backend → redirects to /event on success + * - Backend failure → sets error status → redirects to /auth/login + * - Token extraction edge cases (malformed hash, partial tokens) + */ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ─── Mocks ─────────────────────────────────────────────────────────────────── + +const mockRouterPush = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: mockRouterPush, + refresh: vi.fn(), + }), +})); + +// The mutation function and its control handles, set per test via mockImplementation. +const mockMutate = vi.fn(); +let capturedCallbacks: { + onSuccess?: (res: unknown) => void; + onError?: (err: Error) => void; +} = {}; + +vi.mock("@/hooks/mutations/use-auth-callback", () => ({ + useAuthCallbackMutation: () => ({ + mutate: mockMutate, + isPending: false, + }), +})); + +// Helper: set window.location.hash to simulate OAuth redirect URL +function setLocationHash(hash: string) { + Object.defineProperty(window, "location", { + writable: true, + value: { ...window.location, hash }, + }); +} + +// ─── Import under test (must come after vi.mock calls) ─────────────────────── +import { useAuthCallback } from "./use-handle-auth-callback"; + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("useAuthCallback", () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedCallbacks = {}; + + // Default mutate implementation: capture the callbacks so tests can + // trigger onSuccess / onError imperatively. + mockMutate.mockImplementation( + ( + _params: unknown, + callbacks: { onSuccess?: (res: unknown) => void; onError?: (err: Error) => void } + ) => { + capturedCallbacks = callbacks ?? {}; + } + ); + }); + + afterEach(() => { + // Reset hash between tests + setLocationHash(""); + }); + + // ── No tokens present ────────────────────────────────────────────────── + + it("redirects to /auth/login immediately when the URL hash is empty", async () => { + setLocationHash(""); + + renderHook(() => useAuthCallback()); + + await waitFor(() => { + expect(mockRouterPush).toHaveBeenCalledWith("/auth/login"); + }); + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it("redirects to /auth/login when the hash contains neither access_token nor refresh_token", async () => { + setLocationHash("#some_other_param=value"); + + renderHook(() => useAuthCallback()); + + await waitFor(() => { + expect(mockRouterPush).toHaveBeenCalledWith("/auth/login"); + }); + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it("redirects to /auth/login when access_token is present but refresh_token is missing", async () => { + setLocationHash("#access_token=abc"); + + renderHook(() => useAuthCallback()); + + await waitFor(() => { + expect(mockRouterPush).toHaveBeenCalledWith("/auth/login"); + }); + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it("redirects to /auth/login when refresh_token is present but access_token is missing", async () => { + setLocationHash("#refresh_token=xyz"); + + renderHook(() => useAuthCallback()); + + await waitFor(() => { + expect(mockRouterPush).toHaveBeenCalledWith("/auth/login"); + }); + expect(mockMutate).not.toHaveBeenCalled(); + }); + + // ── Token submission ─────────────────────────────────────────────────── + + it("calls mutate with the extracted tokens when both are present in the URL hash", async () => { + setLocationHash("#access_token=AT123&refresh_token=RT456&token_type=bearer"); + + renderHook(() => useAuthCallback()); + + await waitFor(() => { + expect(mockMutate).toHaveBeenCalledWith( + { access_token: "AT123", refresh_token: "RT456" }, + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }) + ); + }); + }); + + // ── Success path ─────────────────────────────────────────────────────── + + it("sets status to 'success' and redirects to /event after a successful callback", async () => { + setLocationHash("#access_token=AT&refresh_token=RT"); + + const { result } = renderHook(() => useAuthCallback()); + + // Wait for the mutate call so capturedCallbacks is populated + await waitFor(() => expect(mockMutate).toHaveBeenCalled()); + + act(() => { + capturedCallbacks.onSuccess?.({ + success: true, + message: "ok", + user: { id: "u1", email: "a@b.com", name: "Alice" }, + }); + }); + + await waitFor(() => { + expect(result.current.status).toBe("success"); + expect(result.current.message).toBe("Authentication successful! Redirecting..."); + expect(mockRouterPush).toHaveBeenCalledWith("/event"); + }); + }); + + it("exposes the returned userInfo on success", async () => { + setLocationHash("#access_token=AT&refresh_token=RT"); + + const { result } = renderHook(() => useAuthCallback()); + + await waitFor(() => expect(mockMutate).toHaveBeenCalled()); + + act(() => { + capturedCallbacks.onSuccess?.({ + success: true, + message: "ok", + user: { + id: "u2", + email: "bob@example.com", + name: "Bob", + avatar_url: "https://cdn.example.com/bob.jpg", + }, + }); + }); + + await waitFor(() => { + expect(result.current.userInfo).toMatchObject({ + id: "u2", + email: "bob@example.com", + name: "Bob", + avatar_url: "https://cdn.example.com/bob.jpg", + }); + }); + }); + + it("sets userInfo to null when the server response contains no user", async () => { + setLocationHash("#access_token=AT&refresh_token=RT"); + + const { result } = renderHook(() => useAuthCallback()); + + await waitFor(() => expect(mockMutate).toHaveBeenCalled()); + + act(() => { + capturedCallbacks.onSuccess?.({ success: true, message: "ok", user: undefined }); + }); + + await waitFor(() => { + expect(result.current.userInfo).toBeNull(); + }); + }); + + // ── Error path ───────────────────────────────────────────────────────── + + it("sets status to 'error' and shows an error message on backend failure", async () => { + setLocationHash("#access_token=AT&refresh_token=RT"); + + const { result } = renderHook(() => useAuthCallback()); + + await waitFor(() => expect(mockMutate).toHaveBeenCalled()); + + act(() => { + capturedCallbacks.onError?.(new Error("Token rejected")); + }); + + await waitFor(() => { + expect(result.current.status).toBe("error"); + expect(result.current.message).toBe("Authentication failed. Please try again."); + }); + }); + + it("redirects to /auth/login after a short delay on error", async () => { + // Use shouldAdvanceTime so waitFor's internal polling still works while + // we can also fast-forward the hook's 500 ms redirect timeout. + vi.useFakeTimers({ shouldAdvanceTime: true }); + setLocationHash("#access_token=AT&refresh_token=RT"); + + renderHook(() => useAuthCallback()); + + await waitFor(() => expect(mockMutate).toHaveBeenCalled()); + + act(() => { + capturedCallbacks.onError?.(new Error("Server error")); + }); + + // Fast-forward past the 500 ms setTimeout inside the hook + await act(async () => { + vi.advanceTimersByTime(600); + }); + + expect(mockRouterPush).toHaveBeenCalledWith("/auth/login"); + + vi.useRealTimers(); + }); + + // ── Initial state ────────────────────────────────────────────────────── + + it("starts with status 'idle', empty message and null userInfo", () => { + setLocationHash("#access_token=AT&refresh_token=RT"); + + const { result } = renderHook(() => useAuthCallback()); + + // Before any async resolution the initial state should be idle + expect(result.current.status).toBe("idle"); + expect(result.current.message).toBe(""); + expect(result.current.userInfo).toBeNull(); + }); +}); diff --git a/apps/web/src/modules/auth/login/LoginSection.tsx b/apps/web/src/modules/auth/login/LoginSection.tsx index 5465b0f..6dcd6c7 100644 --- a/apps/web/src/modules/auth/login/LoginSection.tsx +++ b/apps/web/src/modules/auth/login/LoginSection.tsx @@ -1,13 +1,11 @@ "use client"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, 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 { createClient } from "@/lib/supabase"; import { AuthenticatedUserLogin } from "./AuthenticatedUserLogin"; import { UnAuthenticatedUserLogin } from "./UnauthenticatedUserLogin"; @@ -15,6 +13,7 @@ export function LoginSection() { const { user, isLoading, refetch } = useSession(); const router = useRouter(); const [rateLimitMessage, setRateLimitMessage] = useState(""); + const supabase = useMemo(() => createClient(), []); // Rate limit: 5 login attempts per minute const loginRateLimit = useRateLimit({ @@ -54,24 +53,28 @@ export function LoginSection() { }, [user, isLoading, router]); const handleGoogleSignIn = useCallback(async () => { - const { success } = await loginRateLimit.execute(() => { - // Redirect to your backend's Google auth endpoint - router.push(getSignInLink()); + const { success } = await loginRateLimit.execute(async () => { + const { error } = await supabase.auth.signInWithOAuth({ + provider: "google", + options: { + redirectTo: `${window.location.origin}/auth/callback`, + }, + }); + + if (error) { + console.error("Error signing in:", error); + setRateLimitMessage(error.message); + } }); if (!success && rateLimitMessage) { console.warn(rateLimitMessage); } - }, [router, loginRateLimit, rateLimitMessage]); + }, [supabase, loginRateLimit, rateLimitMessage]); const handleSignOut = useCallback(async () => { const { success } = await logoutRateLimit.execute(async () => { - await client.api.auth.logout.post( - {}, - { - ...authHeaders, - } - ); + await supabase.auth.signOut(); refetch(); router.refresh(); }); @@ -79,7 +82,7 @@ export function LoginSection() { if (!success && rateLimitMessage) { console.warn(rateLimitMessage); } - }, [refetch, router, logoutRateLimit, rateLimitMessage]); + }, [supabase, refetch, router, logoutRateLimit, rateLimitMessage]); if (isLoading) { return ( diff --git a/turbo.json b/turbo.json index d12a460..7837deb 100644 --- a/turbo.json +++ b/turbo.json @@ -5,7 +5,7 @@ "build": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], - "outputs": [".next/**", "!.next/cache/**"] + "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, "lint": { "dependsOn": ["^lint"] diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..43fda22 --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "buildCommand": "bun install && bun run build --filter=web", + "devCommand": "bun run dev --filter=web", + "installCommand": "bun install", + "framework": "nextjs", + "outputDirectory": "apps/web/.next" +} From 9e392afdb09ef5ada23534444478afb98b031e0c Mon Sep 17 00:00:00 2001 From: Methasit-Pun Date: Sat, 11 Apr 2026 06:27:24 +0700 Subject: [PATCH 3/4] feat: add staffsection and Gantt section --- apps/backend/src/modules/event/event.route.ts | 21 +- apps/web/src/app/event/page.tsx | 427 +++++++++++------- apps/web/src/hooks/use-get-event.ts | 19 + apps/web/src/modules/event/AgendaSection.tsx | 221 ++++++++- apps/web/src/modules/event/ExtraSection.tsx | 152 +++++++ apps/web/src/modules/event/GanttSection.tsx | 262 +++++++++++ .../web/src/modules/event/OverviewSection.tsx | 219 ++++++--- apps/web/src/modules/event/StaffSection.tsx | 137 ++++++ 8 files changed, 1233 insertions(+), 225 deletions(-) create mode 100644 apps/web/src/hooks/use-get-event.ts create mode 100644 apps/web/src/modules/event/ExtraSection.tsx create mode 100644 apps/web/src/modules/event/GanttSection.tsx create mode 100644 apps/web/src/modules/event/StaffSection.tsx diff --git a/apps/backend/src/modules/event/event.route.ts b/apps/backend/src/modules/event/event.route.ts index 4d500f0..2dc49f5 100644 --- a/apps/backend/src/modules/event/event.route.ts +++ b/apps/backend/src/modules/event/event.route.ts @@ -1,4 +1,4 @@ -import { Elysia } from "elysia"; +import { Elysia, t } 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"; @@ -9,7 +9,7 @@ import { EventSchema, } from "#backend/shared/schemas"; import { EventRepository } from "./event.repository"; -import { createEvent, listEvents } from "./services/crud-event.service"; +import { createEvent, getEventById, listEvents } from "./services/crud-event.service"; const eventRepository = new EventRepository(db); @@ -22,6 +22,23 @@ export const eventRouter = new Elysia({ prefix: "/api/event" }) }) ) .use(optionalAuthMiddleware) + .get( + "/:id", + async ({ params, error }) => { + const event = await getEventById(eventRepository, params.id); + if (!event) { + return error(404, { error: "Event not found" }); + } + return event; + }, + { + params: t.Object({ id: t.String() }), + response: { + 200: EventSchema, + 404: t.Object({ error: t.String() }), + }, + } + ) .get( "/", async ({ query }) => { diff --git a/apps/web/src/app/event/page.tsx b/apps/web/src/app/event/page.tsx index 4bf8b90..c58dc8b 100644 --- a/apps/web/src/app/event/page.tsx +++ b/apps/web/src/app/event/page.tsx @@ -1,24 +1,35 @@ "use client"; -import { Clock, LogOut, Menu, MoreHorizontal, Search, X } from "lucide-react"; +import { + Bell, + Check, + Clock, + Globe, + Lock, + LogOut, + Menu, + MoreHorizontal, + Search, + X, +} from "lucide-react"; import Image from "next/image"; import { useParams, useRouter } from "next/navigation"; import type React from "react"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/atoms/button"; import { Input } from "@/components/atoms/input"; +import { useGetAgenda } from "@/hooks/use-get-agenda"; +import { useGetEvent } from "@/hooks/use-get-event"; import { useSession } from "@/hooks/use-session"; +import { useSessionManager } from "@/hooks/use-session-manager"; import { createClient } from "@/lib/supabase"; import AgendaSection from "../../modules/event/AgendaSection"; -// Import all section components +import ExtraSection from "../../modules/event/ExtraSection"; +import GanttSection from "../../modules/event/GanttSection"; import OverviewSection from "../../modules/event/OverviewSection"; +import StaffSection from "../../modules/event/StaffSection"; -// import GanttSection from "./components/gantt-section"; -// import TeamsSection from "./components/teams-section"; -// import ExtraSection from "./components/extra-section"; - -// Types for backend readiness -interface Event { +export interface EventData { id: string; name: string; description: string; @@ -29,136 +40,162 @@ interface Event { isPublic: boolean; createdAt: string; updatedAt: string; -} - -interface User { - id: string; - name: string; - email: string; - avatar_url?: string; + createdBy?: string; } interface NavigationItem { id: string; label: string; - component: React.ComponentType<{ eventData: Event }>; + component: React.ComponentType<{ eventData: EventData }>; badge?: number; } -// TODO: Change from static page to supabase -// Mock event data - ready for Supabase integration -const getEventData = (eventId: string): Event => { - const eventMap: Record = { - stupidhackathon9: { - id: "stupidhackathon9", - name: "Stupid Hackathon #9", - description: "The most ridiculous hackathon in Thailand", - startDate: "2025-07-26", - endDate: "2025-07-27", - location: "Bangkok, Thailand", - type: "hackathon", - isPublic: true, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - }, - stupidhackathon8: { - id: "stupidhackathon8", - name: "Stupid Hackathon #8", - description: "Previous edition of the hackathon", - startDate: "2024-06-15", - endDate: "2024-06-17", - location: "Bangkok, Thailand", - type: "hackathon", - isPublic: true, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - }, - }; - return ( - eventMap[eventId] || { - id: eventId, - name: "Event", - description: "Event description will be added here", - startDate: new Date().toISOString().split("T")[0] ?? "", - endDate: new Date(Date.now() + 86400000).toISOString().split("T")[0] ?? "", - location: "TBD", - type: "custom", - isPublic: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } - ); +const FALLBACK_EVENT: EventData = { + id: "", + name: "Event", + description: "", + startDate: new Date().toISOString().split("T")[0] ?? "", + endDate: new Date(Date.now() + 86400000).toISOString().split("T")[0] ?? "", + location: "TBD", + type: "custom", + isPublic: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), }; export default function EventManagementSPA() { const params = useParams(); const router = useRouter(); const eventId = params.eventId as string; + const [currentTime, setCurrentTime] = useState(new Date()); const [hasMounted, setHasMounted] = useState(false); const [activeSection, setActiveSection] = useState("overview"); - const [eventData] = useState(() => - //setEventData - getEventData(eventId) - ); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); + const [isNotifOpen, setIsNotifOpen] = useState(false); + const [isPublic, setIsPublic] = useState(true); + const [savedFeedback, setSavedFeedback] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const userMenuRef = useRef(null); + const notifRef = useRef(null); - // Use the client-side session hook instead of server action const { user } = useSession(); const supabase = createClient(); + // Fetch real event data from backend + const { event: backendEvent, isLoading: eventLoading } = useGetEvent(eventId); + + // Build merged event data: backend fields take precedence, fall back to defaults + const eventData: EventData = backendEvent + ? { + id: String(backendEvent.id ?? eventId), + name: String(backendEvent.name ?? "Event"), + description: String(backendEvent.description ?? ""), + startDate: + backendEvent.startDate instanceof Date + ? ((backendEvent.startDate as Date).toISOString().split("T")[0] ?? "") + : (String(backendEvent.startDate ?? "").split("T")[0] ?? ""), + endDate: + backendEvent.endDate instanceof Date + ? ((backendEvent.endDate as Date).toISOString().split("T")[0] ?? "") + : (String(backendEvent.endDate ?? "").split("T")[0] ?? ""), + location: String(backendEvent.location ?? "TBD"), + type: "custom", + isPublic, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: String(backendEvent.createdBy ?? ""), + } + : { ...FALLBACK_EVENT, id: eventId, isPublic }; + + // Session manager for AP notation notifications + const { getLatestAPNotation, sessionEnds } = useSessionManager(); + const { data: agendaData } = useGetAgenda(); + + // Build notification list from late sessions + const notifications = sessionEnds + .filter((se) => se.difference > 0) + .sort((a, b) => b.actualEndTime.getTime() - a.actualEndTime.getTime()) + .slice(0, 10) + .map((se) => { + const slot = (agendaData ?? []).find( + (s: { id: string; activity?: string }) => s.id === se.slotId + ); + return { + id: se.slotId, + message: `"${slot?.activity ?? se.slotId}" ended AP+${se.difference}m late`, + time: se.actualEndTime, + }; + }); + + const latestAP = hasMounted ? getLatestAPNotation() : null; + const unreadCount = notifications.length; + const handleSignOut = async () => { await supabase.auth.signOut(); router.push("/auth/login"); }; - // Close the user menu when clicking outside + const handleSave = useCallback(() => { + // Sections auto-save via mutations; this triggers visual confirmation + setSavedFeedback(true); + setTimeout(() => setSavedFeedback(false), 2000); + }, []); + + // Close dropdowns on outside click useEffect(() => { function handleClickOutside(e: MouseEvent) { if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) { setIsUserMenuOpen(false); } + if (notifRef.current && !notifRef.current.contains(e.target as Node)) { + setIsNotifOpen(false); + } } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); - // Navigation items - all ready for backend - const navigationItems: NavigationItem[] = [ - { id: "overview", label: "Overview", component: OverviewSection }, - { id: "agenda", label: "Agenda (AP)", component: AgendaSection }, - // { id: "gantt", label: "Gantt Chart", component: GanttSection }, - // { id: "teams", label: "Staff & Participant", component: TeamsSection }, - // { id: "extra", label: "Extra", component: ExtraSection }, - ]; - useEffect(() => { setHasMounted(true); - const timer = setInterval(() => { - setCurrentTime(new Date()); - }, 1000); - + const timer = setInterval(() => setCurrentTime(new Date()), 1000); return () => clearInterval(timer); }, []); + // Sync isPublic from backend when it loads (default true) + useEffect(() => { + if (backendEvent) setIsPublic(true); + }, [backendEvent]); + const formatTime = (date: Date) => { - const hours = date.getHours().toString().padStart(2, "0"); - const minutes = date.getMinutes().toString().padStart(2, "0"); - const seconds = date.getSeconds().toString().padStart(2, "0"); - return `${hours}:${minutes}:${seconds}`; + const h = date.getHours().toString().padStart(2, "0"); + const m = date.getMinutes().toString().padStart(2, "0"); + const s = date.getSeconds().toString().padStart(2, "0"); + return `${h}:${m}:${s}`; }; + const navigationItems: NavigationItem[] = [ + { id: "overview", label: "Overview", component: OverviewSection }, + { id: "agenda", label: "Agenda (AP)", component: AgendaSection }, + { id: "gantt", label: "Gantt Chart", component: GanttSection }, + { id: "teams", label: "Staff & Participant", component: StaffSection }, + { id: "extra", label: "Extra", component: ExtraSection }, + ]; + + // Filter navigation by search + const filteredNavItems = searchQuery + ? navigationItems.filter((item) => item.label.toLowerCase().includes(searchQuery.toLowerCase())) + : navigationItems; + const handleSectionChange = (sectionId: string) => { setActiveSection(sectionId); - // Ready for URL state management and analytics tracking - // window.history.pushState({}, '', `/${eventId}/${sectionId}`) + window.history.replaceState({}, "", `?section=${sectionId}`); }; - // Get current section component const currentSection = navigationItems.find((item) => item.id === activeSection); - const CurrentComponent = currentSection?.component || OverviewSection; + const CurrentComponent = currentSection?.component ?? OverviewSection; return (
@@ -167,27 +204,23 @@ export default function EventManagementSPA() {
setIsMobileMenuOpen(false)} - onKeyDown={(e) => { - if (e.key === "Escape") { - setIsMobileMenuOpen(false); - } - }} + onKeyDown={(e) => e.key === "Escape" && setIsMobileMenuOpen(false)} role="button" tabIndex={0} aria-label="Close mobile menu" /> )} - {/* Left Sidebar - Now responsive */} + {/* Left Sidebar */}
- {/* Mobile close button */} + {/* Mobile close */}
))}
-
+ + {/* Notifications entry */} +
+ {unreadCount > 0 && ( + + {unreadCount} + + )} + + + {/* Latest AP notation badge */} + {latestAP && ( +
+ Event running: {latestAP.notation} +
+ )}
- {/* Bottom Section - Simplified for mobile */} + {/* Bottom Section */}
-
-
+ +
ติดต่อซัพพอร์ต -
-
-
+
+
- -
-
Used space
-
- Your team has used 80% of your available space. Need more? -
-
- - -
+
@@ -297,25 +348,23 @@ export default function EventManagementSPA() { ) : (
- {user?.name?.charAt(0)?.toUpperCase() || "U"} + {user?.name?.charAt(0)?.toUpperCase() ?? "U"}
)}
-
{user?.name || "Loading..."}
-
{user?.email || "Loading..."}
+
{user?.name ?? "Loading..."}
+
{user?.email ?? "Loading..."}
- {/* Three-dot menu */}
- {isUserMenuOpen && (
/ - {currentSection?.label || "Overview"} + {currentSection?.label ?? "Overview"}

- {eventData.name} + {eventLoading ? "Loading..." : eventData.name}

{hasMounted ? formatTime(currentTime) : "--:--:--"} + {latestAP && ( + + {latestAP.notation} + + )}
+ {/* Notifications bell */} +
+ + + {isNotifOpen && ( +
+
+ การแจ้งเตือน + {unreadCount > 0 && ( + {unreadCount} รายการ + )} +
+
+ {notifications.length === 0 ? ( +
+ ไม่มีการแจ้งเตือน +
+ ) : ( + notifications.map((n) => ( +
+

{n.message}

+

+ {n.time.toLocaleTimeString("th-TH")} +

+
+ )) + )} +
+
+ )} +
+ + {/* Save button */} + + {/* Public / Private toggle */}
diff --git a/apps/web/src/hooks/use-get-event.ts b/apps/web/src/hooks/use-get-event.ts new file mode 100644 index 0000000..0f72635 --- /dev/null +++ b/apps/web/src/hooks/use-get-event.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import { client } from "@/lib/client"; + +export function useGetEvent(eventId: string | undefined) { + const { data, isLoading, error } = useQuery({ + queryKey: ["event", eventId], + // Eden Treaty dynamic route: GET /api/event/:id + // eslint-disable-next-line @typescript-eslint/no-explicit-any + queryFn: () => (client.api.event as any)[eventId!].get() as Promise<{ data: unknown }>, + enabled: !!eventId, + }); + + return { + // biome-ignore lint/suspicious/noExplicitAny: treaty dynamic route typing + event: (data as any)?.data as Record | null | undefined, + isLoading, + error, + }; +} diff --git a/apps/web/src/modules/event/AgendaSection.tsx b/apps/web/src/modules/event/AgendaSection.tsx index d9d33f1..cbfd8ac 100644 --- a/apps/web/src/modules/event/AgendaSection.tsx +++ b/apps/web/src/modules/event/AgendaSection.tsx @@ -1,9 +1,9 @@ "use client"; -import { Edit, GripVertical, Trash2 } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { Download, Edit, GripVertical, Printer, Trash2, X } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { EventData } from "@/app/event/page"; import { Button } from "@/components/atoms/button"; -// shadcn dialog imports import { Dialog, DialogClose, @@ -17,6 +17,7 @@ import { Input } from "@/components/atoms/input"; import { useCreateAgenda } from "@/hooks/use-create-agenda"; import { useDeleteAgenda } from "@/hooks/use-delete-agenda"; import { useGetAgenda } from "@/hooks/use-get-agenda"; +import { useSessionManager } from "@/hooks/use-session-manager"; import { useUpdateAgenda } from "@/hooks/use-update-agenda"; type AgendaSlot = { @@ -53,36 +54,41 @@ const agendaHeaders = [ "Activity", "Person in Charge", "Remarks", + "Status", "Actions", ]; -export default function AgendaSection() { - const eventId = "static-event-1"; +export default function AgendaSection({ eventData }: { eventData: EventData }) { + const eventId = eventData.id; const [currentDay, setCurrentDay] = useState(1); const [hasMounted, setHasMounted] = useState(false); + const [sessionErrors, setSessionErrors] = useState>({}); + const [endingSlots, setEndingSlots] = useState>({}); - // Calculate event dates based on a static event (you can modify this to use props) - const eventStartDate = "2025-07-26"; // Day 1 const eventDays = useMemo(() => { - const startDate = new Date(eventStartDate); - // Assuming 3 days event, you can modify this logic - return Array.from({ length: 3 }, (_, i) => { - const date = new Date(startDate); - date.setDate(startDate.getDate() + i); + const start = new Date(eventData.startDate); + const end = new Date(eventData.endDate); + const dayCount = Math.max(1, Math.round((end.getTime() - start.getTime()) / 86400000) + 1); + return Array.from({ length: dayCount }, (_, i) => { + const date = new Date(start); + date.setDate(start.getDate() + i); return { day: i + 1, - date: date.toISOString().split("T")[0], // YYYY-MM-DD format + date: date.toISOString().split("T")[0] ?? "", displayDate: date.toLocaleDateString("en-US", { month: "short", day: "numeric", }), }; }); - }, []); + }, [eventData.startDate, eventData.endDate]); const currentDayData = eventDays[currentDay - 1]; - const { data: agendaSlots, isLoading, error, refetch } = useGetAgenda(); //eventId, currentDay - previous input + const { data: agendaSlots, isLoading, error, refetch } = useGetAgenda(); + + const { handleEndSession, handleUndoSession, getSessionEndInfo, formatAPNotation } = + useSessionManager(); // Dialog state const [open, setOpen] = useState(false); @@ -328,6 +334,45 @@ export default function AgendaSection() { } }; + // AP notation helper — checks local session state first, then backend actualEndTime + const getAPForSlot = (slot: AgendaSlot): { notation: string; isLate: boolean } | null => { + const localInfo = getSessionEndInfo(slot.id); + if (localInfo) { + return { + notation: formatAPNotation(localInfo.difference), + isLate: localInfo.difference > 0, + }; + } + if (slot.actualEndTime) { + const diff = Math.round( + (new Date(slot.actualEndTime).getTime() - new Date(slot.end).getTime()) / 60000 + ); + return { notation: formatAPNotation(diff), isLate: diff > 0 }; + } + return null; + }; + + const handleEndSessionClick = async (slot: AgendaSlot) => { + setSessionErrors((prev) => ({ ...prev, [slot.id]: "" })); + setEndingSlots((prev) => ({ ...prev, [slot.id]: true })); + try { + await handleEndSession(slot); + refetch(); + } catch (err) { + setSessionErrors((prev) => ({ + ...prev, + [slot.id]: err instanceof Error ? err.message : "Failed to end session", + })); + } finally { + setEndingSlots((prev) => ({ ...prev, [slot.id]: false })); + } + }; + + const handleUndoSessionClick = (slot: AgendaSlot) => { + handleUndoSession(slot); + refetch(); + }; + const sortedSlots = useMemo(() => { if (!agendaSlots || !Array.isArray(agendaSlots)) return []; @@ -411,6 +456,40 @@ export default function AgendaSection() { }); }, [agendaSlots, currentDayData]); + const handleDownloadCSV = useCallback(() => { + const headers = ["Slot", "Start", "End", "Activity", "Person in Charge", "Remarks", "Status"]; + const rows = sortedSlots.map((slot, idx) => { + const ap = getAPForSlot(slot); + const fmt = (d: string) => + new Date(d).toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "Asia/Bangkok", + }); + const esc = (s: string) => `"${s.replace(/"/g, '""')}"`; + return [ + idx + 1, + fmt(slot.start), + fmt(slot.end), + esc(slot.activity), + esc(slot.personincharge), + esc(slot.remarks ?? ""), + ap ? ap.notation : "–", + ].join(","); + }); + const csv = [headers.join(","), ...rows].join("\n"); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `agenda-day${currentDay}-${currentDayData?.date ?? ""}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, [sortedSlots, currentDay, currentDayData]); + useEffect(() => { setHasMounted(true); }, []); @@ -462,6 +541,29 @@ export default function AgendaSection() { ))}
+
+ + +
+ + )} +
+ ); + } + return ( +
+ + {err &&

{err}

} +
+ ); + })()} +
+ )} + + ) : ( + <> + + {err &&

{err}

} + + )} +
+ ); + })()} +
+ +
+ + {activeTab === "notes" && ( +
+
+

Event Notes

+ Auto-saved +
+