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/api/index.ts b/apps/backend/api/index.ts index 7e6ed74..3e5d83c 100644 --- a/apps/backend/api/index.ts +++ b/apps/backend/api/index.ts @@ -1,10 +1,29 @@ -// import type { VercelRequest, VercelResponse } from "@vercel/node"; +import type { VercelRequest, VercelResponse } from "@vercel/node"; import { app } from "../src"; -export const config = { - runtime: "edge", // Required for edge deployment -}; +export default async function handler(req: VercelRequest, res: VercelResponse) { + // Convert Vercel request to Web Request + const url = `${req.headers["x-forwarded-proto"] || "http"}://${req.headers.host}${req.url}`; + const headers = new Headers(); + Object.entries(req.headers).forEach(([key, value]) => { + if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value); + }); -export default async function handler(request: Request): Promise { - return app.handle(request); + const request = new Request(url, { + method: req.method, + headers, + body: req.method !== "GET" && req.method !== "HEAD" ? JSON.stringify(req.body) : undefined, + }); + + // Handle with Elysia + const response = await app.handle(request); + + // Convert Web Response to Vercel Response + res.status(response.status); + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + const body = await response.text(); + res.send(body); } diff --git a/apps/backend/package.json b/apps/backend/package.json index 24899ac..9c08e44 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -4,7 +4,7 @@ "scripts": { "dev": "bun --watch src/index.ts", "dev:cf": "wrangler dev", - "build": "bun build src/index.ts --outdir ./dist --target=node", + "build": "bun build src/index.ts --outdir ./dist --target=node --minify --external @supabase/supabase-js --external postgres --external drizzle-orm", "start": "bun run dist/index.js", "typecheck": "bun tsc --noEmit", "db:migrate": "drizzle-kit migrate --config=drizzle.config.ts", 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.test.ts b/apps/backend/src/modules/auth/auth.route.test.ts new file mode 100644 index 0000000..9a83203 --- /dev/null +++ b/apps/backend/src/modules/auth/auth.route.test.ts @@ -0,0 +1,360 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// ─── Hoist mock stubs so they are available inside vi.mock() factories ──────── +// vi.mock() calls are hoisted to the top of the file by Vitest, so any +// variables referenced inside a factory must also be hoisted via vi.hoisted(). + +const { mockGetUser, mockSetSession, mockFind, mockFindByEmail, mockCreate } = vi.hoisted(() => ({ + mockGetUser: vi.fn(), + mockSetSession: vi.fn(), + mockFind: vi.fn(), + mockFindByEmail: vi.fn(), + mockCreate: vi.fn(), +})); + +// ─── Mock all external dependencies before importing the module under test ─── + +vi.mock("#backend/infrastructure/db", () => ({ + db: {}, +})); + +vi.mock("#backend/infrastructure/db/supabase", () => ({ + supabase: { + auth: { + getUser: mockGetUser, + setSession: mockSetSession, + }, + }, +})); + +vi.mock("#backend/modules/user/user.repository", () => ({ + UserRepository: vi.fn().mockImplementation(() => ({ + find: mockFind, + findByEmail: mockFindByEmail, + create: mockCreate, + })), +})); + +// Bypass the rate-limit plugin so tests are not throttled. +vi.mock("../../shared/middleware/rate-limit.middleware", async () => { + const { Elysia } = await import("elysia"); + return { + rateLimit: vi.fn().mockReturnValue(new Elysia()), + rateLimitPresets: { strict: { max: 5, duration: 60000 } }, + }; +}); + +// ─── Import the router under test (must be after all vi.mock() calls) ──────── +import { authRouter } from "./auth.route"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Send a request to the Elysia router and return the parsed JSON response. + */ +async function handleRequest( + method: string, + path: string, + options?: { body?: unknown; headers?: Record } +) { + const request = new Request(`http://localhost${path}`, { + method, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + body: options?.body !== undefined ? JSON.stringify(options.body) : undefined, + }); + + const response = await authRouter.handle(request); + + let data: unknown; + try { + data = await response.json(); + } catch { + data = null; + } + + return { response, data }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("Auth Router – GET /api/auth/session", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return { user: null } when no session cookie is present", async () => { + // Default mock returns undefined → reveals the missing null-guard bug. + const { response, data } = await handleRequest("GET", "/api/auth/session"); + + expect(response.status).toBe(200); + expect(data).toEqual({ user: null }); + }); + + it("should return { user: null } when supabase returns an error", async () => { + mockGetUser.mockResolvedValueOnce({ + data: { user: null }, + error: new Error("invalid JWT"), + }); + + const { response, data } = await handleRequest("GET", "/api/auth/session", { + headers: { Cookie: "session=bad-token" }, + }); + + expect(response.status).toBe(200); + expect(data).toEqual({ user: null }); + }); + + it("should return { user: null } when the user is not found in the database", async () => { + mockGetUser.mockResolvedValueOnce({ + data: { user: { id: "supabase-uid-1" } }, + error: null, + }); + mockFind.mockResolvedValueOnce(null); + + const { response, data } = await handleRequest("GET", "/api/auth/session", { + headers: { Cookie: "session=valid-token" }, + }); + + expect(response.status).toBe(200); + expect(data).toEqual({ user: null }); + }); + + it("should return the user object when the session is valid and the user exists", async () => { + mockGetUser.mockResolvedValueOnce({ + data: { user: { id: "supabase-uid-1" } }, + error: null, + }); + mockFind.mockResolvedValueOnce({ + id: "db-user-id", + email: "alice@example.com", + name: "Alice", + avatarUrl: "https://cdn.example.com/alice.jpg", + }); + + const { response, data } = await handleRequest("GET", "/api/auth/session", { + headers: { Cookie: "session=valid-token" }, + }); + + expect(response.status).toBe(200); + expect((data as Record).user).toMatchObject({ + id: "db-user-id", + email: "alice@example.com", + name: "Alice", + avatar_url: "https://cdn.example.com/alice.jpg", + }); + }); + + it("should omit avatar_url from the response when the user has no avatar", async () => { + mockGetUser.mockResolvedValueOnce({ + data: { user: { id: "supabase-uid-2" } }, + error: null, + }); + mockFind.mockResolvedValueOnce({ + id: "db-user-id-2", + email: "bob@example.com", + name: "Bob", + avatarUrl: null, + }); + + const { data } = await handleRequest("GET", "/api/auth/session", { + headers: { Cookie: "session=valid-token" }, + }); + + const user = (data as Record).user as Record; + expect(user).not.toHaveProperty("avatar_url"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe("Auth Router – POST /api/auth/callback", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const validBody = { + access_token: "google-access-token-abc", + refresh_token: "google-refresh-token-xyz", + }; + + const mockSupabaseSession = { access_token: "new-session-token" }; + + const mockSupabaseUser = { + id: "supabase-uid-99", + email: "carol@example.com", + user_metadata: { + full_name: "Carol", + avatar_url: "https://cdn.example.com/carol.jpg", + picture: null, + }, + }; + + it("should create a new user and return success when the user does not exist yet", async () => { + mockSetSession.mockResolvedValueOnce({ + data: { session: mockSupabaseSession, user: mockSupabaseUser }, + error: null, + }); + mockFindByEmail.mockResolvedValueOnce(null); // user doesn't exist + mockCreate.mockResolvedValueOnce({}); + + const { response, data } = await handleRequest("POST", "/api/auth/callback", { + body: validBody, + }); + + expect(response.status).toBe(200); + const res = data as Record; + expect(res.success).toBe(true); + expect((res.user as Record).email).toBe("carol@example.com"); + + expect(mockCreate).toHaveBeenCalledOnce(); + expect(mockCreate).toHaveBeenCalledWith({ + id: "supabase-uid-99", + email: "carol@example.com", + name: "Carol", + avatar_url: "https://cdn.example.com/carol.jpg", + }); + }); + + it("should skip user creation when the user already exists", async () => { + mockSetSession.mockResolvedValueOnce({ + data: { session: mockSupabaseSession, user: mockSupabaseUser }, + error: null, + }); + mockFindByEmail.mockResolvedValueOnce({ + id: "existing-db-id", + email: "carol@example.com", + }); + + const { response, data } = await handleRequest("POST", "/api/auth/callback", { + body: validBody, + }); + + expect(response.status).toBe(200); + expect((data as Record).success).toBe(true); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("should fall back to picture when avatar_url is absent in user_metadata", async () => { + const userWithPictureOnly = { + ...mockSupabaseUser, + user_metadata: { + full_name: "Carol", + avatar_url: null, + picture: "https://cdn.example.com/carol-picture.jpg", + }, + }; + + mockSetSession.mockResolvedValueOnce({ + data: { session: mockSupabaseSession, user: userWithPictureOnly }, + error: null, + }); + mockFindByEmail.mockResolvedValueOnce(null); + mockCreate.mockResolvedValueOnce({}); + + const { data } = await handleRequest("POST", "/api/auth/callback", { + body: validBody, + }); + + const user = (data as Record).user as Record; + expect(user.avatar_url).toBe("https://cdn.example.com/carol-picture.jpg"); + }); + + it("should not include avatar_url in the response when both avatar_url and picture are absent", async () => { + const userNoAvatar = { + ...mockSupabaseUser, + user_metadata: { full_name: "Carol", avatar_url: null, picture: null }, + }; + + mockSetSession.mockResolvedValueOnce({ + data: { session: mockSupabaseSession, user: userNoAvatar }, + error: null, + }); + mockFindByEmail.mockResolvedValueOnce(null); + mockCreate.mockResolvedValueOnce({}); + + const { data } = await handleRequest("POST", "/api/auth/callback", { + body: validBody, + }); + + const user = (data as Record).user as Record; + expect(user).not.toHaveProperty("avatar_url"); + }); + + it("should respond 500 when supabase.setSession returns an error", async () => { + mockSetSession.mockResolvedValueOnce({ + data: { session: null, user: null }, + error: new Error("Token expired"), + }); + + const { response } = await handleRequest("POST", "/api/auth/callback", { + body: validBody, + }); + + expect(response.status).toBe(500); + }); + + it("should respond 500 when the supabase user has no email", async () => { + mockSetSession.mockResolvedValueOnce({ + data: { + session: mockSupabaseSession, + user: { ...mockSupabaseUser, email: null }, + }, + error: null, + }); + + const { response } = await handleRequest("POST", "/api/auth/callback", { + body: validBody, + }); + + expect(response.status).toBe(500); + }); + + /** + * Validates the Elysia body schema (AuthCallbackSchema) rejects incomplete + * payloads. Elysia returns 400 (not 422) for body validation failures. + */ + it("should respond 400 when refresh_token is missing from the request body", async () => { + const { response } = await handleRequest("POST", "/api/auth/callback", { + body: { access_token: "only-access" }, + }); + + expect(response.status).toBe(400); + }); + + it("should respond 400 when the request body is empty", async () => { + const { response } = await handleRequest("POST", "/api/auth/callback", { + body: {}, + }); + + expect(response.status).toBe(400); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe("Auth Router – POST /api/auth/logout", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 200 and success message when a session cookie is present", async () => { + const { response, data } = await handleRequest("POST", "/api/auth/logout", { + headers: { Cookie: "session=active-token" }, + }); + + expect(response.status).toBe(200); + const res = data as Record; + expect(res.success).toBe(true); + expect(res.message).toBe("Logged out successfully"); + }); + + it("should return 400 when no session cookie is present", async () => { + const { response, data } = await handleRequest("POST", "/api/auth/logout"); + + expect(response.status).toBe(400); + expect((data as Record).error).toBe("No session to logout"); + }); +}); diff --git a/apps/backend/src/modules/auth/auth.route.ts b/apps/backend/src/modules/auth/auth.route.ts index 1e49d63..d1aa31f 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,14 +14,21 @@ 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 } }) => { - if (!session) { + if (!session?.value) { return { user: null }; } - const { data, error } = await supabase.auth.getUser(session.value); + const { data, error } = await supabase.auth.getUser(session.value as string); if (error || !data.user) { return { user: null }; } @@ -109,7 +117,7 @@ export const authRouter = new Elysia({ prefix: "/api/auth" }) .post( "/logout", ({ cookie: { session }, set }) => { - if (!session) { + if (!session?.value) { set.status = 400; return { error: "No session to logout" }; } diff --git a/apps/backend/src/modules/event/event.repository.ts b/apps/backend/src/modules/event/event.repository.ts index daa60cf..11164ea 100644 --- a/apps/backend/src/modules/event/event.repository.ts +++ b/apps/backend/src/modules/event/event.repository.ts @@ -11,16 +11,21 @@ class EventRepository { async create(event: CreateEventDTO): Promise { try { const id = uuidv4(); + const startDate = new Date(event.startDate); + const endDate = new Date(event.endDate); + await this.db.insert(events).values({ ...event, id, - startDate: new Date(event.startDate), // Convert string to Date for database - endDate: new Date(event.endDate), // Convert string to Date for database + startDate, + endDate, }); return { ...event, id, + startDate, + endDate, }; } catch (error) { if (error instanceof Error) { @@ -49,10 +54,10 @@ class EventRepository { async update(id: string, event: UpdateEventDTO): Promise { try { - const updateData = { + const updateData: Partial = { ...event, - ...(event.startDate && { startDate: new Date(event.startDate) }), // Convert string to Date if provided - ...(event.endDate && { endDate: new Date(event.endDate) }), // Convert string to Date if provided + startDate: new Date(event.startDate), + endDate: new Date(event.endDate), }; const updated = await this.db diff --git a/apps/backend/src/modules/event/event.route.ts b/apps/backend/src/modules/event/event.route.ts index e473779..2dc49f5 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 { 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"; import { CreateEventSchema, EventListResponseSchema, @@ -8,12 +9,36 @@ 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); 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( + "/: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/backend/src/modules/event/services/crud-event.service.ts b/apps/backend/src/modules/event/services/crud-event.service.ts index 6b2c7d8..f50c134 100644 --- a/apps/backend/src/modules/event/services/crud-event.service.ts +++ b/apps/backend/src/modules/event/services/crud-event.service.ts @@ -21,6 +21,7 @@ export async function listEvents( startDate?: string; endDate?: string; location?: string; + createdBy?: string; } ): Promise { // TODO implement query CRUD @@ -44,11 +45,11 @@ export async function listEvents( filtered = filtered.filter((event) => event.location.toLowerCase().includes(query.location.toLowerCase()) ); - if (query?.createdBy) { - filtered = filtered.filter((event) => - event.createdBy.toLowerCase().includes(query.createdBy.toLowerCase()) - ); - } + } + if (query?.createdBy) { + filtered = filtered.filter((event) => + event.createdBy.toLowerCase().includes(query.createdBy.toLowerCase()) + ); } return filtered; } 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/backend/vercel.json b/apps/backend/vercel.json index 4d52424..9ea20dd 100644 --- a/apps/backend/vercel.json +++ b/apps/backend/vercel.json @@ -1,9 +1,16 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "version": 2, + "rewrites": [ + { + "source": "/(.*)", + "destination": "/api/index" + } + ], "functions": { - "api/index.ts": { - "memory": 512 + "api/**/*.ts": { + "memory": 1024, + "maxDuration": 30 } } } diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts new file mode 100644 index 0000000..c2894d5 --- /dev/null +++ b/apps/web/middleware.ts @@ -0,0 +1,19 @@ +import type { NextRequest } from "next/server"; +import { updateSession } from "@/lib/supabase-middleware"; + +export async function middleware(request: NextRequest) { + return await updateSession(request); +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * Feel free to modify this pattern to include more paths. + */ + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +}; 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/auth/callback/page.tsx b/apps/web/src/app/auth/callback/page.tsx deleted file mode 100644 index b9e97e5..0000000 --- a/apps/web/src/app/auth/callback/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { AuthCallback } from "@/modules/auth/callback/AuthCallback"; - -export default function AuthCallbackPage() { - return ; -} diff --git a/apps/web/src/app/auth/callback/route.ts b/apps/web/src/app/auth/callback/route.ts new file mode 100644 index 0000000..e914a90 --- /dev/null +++ b/apps/web/src/app/auth/callback/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase-server"; + +export async function GET(request: Request) { + const requestUrl = new URL(request.url); + const code = requestUrl.searchParams.get("code"); + const origin = requestUrl.origin; + + if (code) { + const supabase = await createClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); + + if (error) { + console.error("Error exchanging code for session:", error); + return NextResponse.redirect(`${origin}/auth/login?error=${error.message}`); + } + } + + // URL to redirect to after sign in process completes + return NextResponse.redirect(`${origin}/event`); +} diff --git a/apps/web/src/app/event/page.tsx b/apps/web/src/app/event/page.tsx index c310e79..c58dc8b 100644 --- a/apps/web/src/app/event/page.tsx +++ b/apps/web/src/app/event/page.tsx @@ -1,23 +1,35 @@ "use client"; -import { Clock, 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 } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import type React from "react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/atoms/button"; import { Input } from "@/components/atoms/input"; -import { getSession } from "@/lib/auth"; +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; @@ -28,126 +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 [user, setUser] = useState(null); + const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); + const [isNotifOpen, setIsNotifOpen] = useState(false); + const [isPublic, setIsPublic] = useState(true); + const [savedFeedback, setSavedFeedback] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); - // 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 }, - ]; + const userMenuRef = useRef(null); + const notifRef = useRef(null); - useEffect(() => { - setHasMounted(true); - const timer = setInterval(() => { - 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); + 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(); - fetchUser(); + // 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"); + }; + + 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); + }, []); + + useEffect(() => { + setHasMounted(true); + 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 (
@@ -156,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? -
-
- - -
+
@@ -286,28 +348,46 @@ 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..."}
+
+ +
+ + {isUserMenuOpen && ( +
+ +
+ )}
-
{/* Main Content Area */}
- {/* Header - Mobile responsive */} + {/* Header */}
- {/* Mobile menu button */} / - {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/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..ff4385c 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -16,6 +16,8 @@ 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(), + NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1), }, /* * Due to how Next.js bundles environment variables on Edge and Client, @@ -26,5 +28,7 @@ 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, + NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, }, }); 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-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/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-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/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 5b45794..3d3fc06 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -1,28 +1,26 @@ "use server"; -import { authHeaders } from "@/config/header"; -import { env } from "@/env"; -import { client } from "./client"; +import { createClient } from "@/lib/supabase-server"; 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({ - headers: { - ...authHeaders, - }, - }); + const supabase = await createClient(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); - if (res.status !== 200) { + if (error || !user) { return null; } - return res?.data?.user; - } catch { - // Silent error handling during build to prevent Vercel build failures + 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) { + 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-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 new file mode 100644 index 0000000..8641ba6 --- /dev/null +++ b/apps/web/src/lib/supabase.ts @@ -0,0 +1,6 @@ +import { createBrowserClient } from "@supabase/ssr"; +import { env } from "@/env"; + +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/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.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/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..6dcd6c7 100644 --- a/apps/web/src/modules/auth/login/LoginSection.tsx +++ b/apps/web/src/modules/auth/login/LoginSection.tsx @@ -1,40 +1,88 @@ "use client"; import { useRouter } from "next/navigation"; -import { useCallback } 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"; export function LoginSection() { const { user, isLoading, refetch } = useSession(); const router = useRouter(); + const [rateLimitMessage, setRateLimitMessage] = useState(""); + const supabase = useMemo(() => createClient(), []); - const handleGoogleSignIn = useCallback(() => { - // Redirect to your backend's Google auth endpoint - router.push(getSignInLink()); - }, [router]); - - const handleSignOut = useCallback(() => { - client.api.auth.logout - .post( - {}, - { - ...authHeaders, - } - ) - .then(() => { - refetch(); - router.refresh(); - }) - .catch((error) => { - console.error("Logout failed:", error); + // 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.` + ); + }, + }); + + // 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(async () => { + const { error } = await supabase.auth.signInWithOAuth({ + provider: "google", + options: { + redirectTo: `${window.location.origin}/auth/callback`, + }, }); - }, [refetch, router]); + + if (error) { + console.error("Error signing in:", error); + setRateLimitMessage(error.message); + } + }); + + if (!success && rateLimitMessage) { + console.warn(rateLimitMessage); + } + }, [supabase, loginRateLimit, rateLimitMessage]); + + const handleSignOut = useCallback(async () => { + const { success } = await logoutRateLimit.execute(async () => { + await supabase.auth.signOut(); + refetch(); + router.refresh(); + }); + + if (!success && rateLimitMessage) { + console.warn(rateLimitMessage); + } + }, [supabase, refetch, router, logoutRateLimit, rateLimitMessage]); if (isLoading) { return ( @@ -52,9 +100,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} +

+
+ )} + + + + + )} + + ); + } + return ( +
+ + {err &&

{err}

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

{err}

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

Event Notes

+ Auto-saved +
+