diff --git a/frontends/nextjs/src/app/api/auth/login/route.ts b/frontends/nextjs/src/app/api/auth/login/route.ts new file mode 100644 index 000000000..b2f5208b8 --- /dev/null +++ b/frontends/nextjs/src/app/api/auth/login/route.ts @@ -0,0 +1,58 @@ +/** + * POST /api/auth/login + * + * Authenticates a user and sets a session cookie. + */ + +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { login } from '@/lib/auth/api/login' +import { db } from '@/lib/db-client' +import crypto from 'crypto' + +export async function POST(request: Request): Promise { + try { + const body = await request.json() as { identifier?: string; password?: string } + const { identifier, password } = body + + if (identifier === undefined || password === undefined) { + return NextResponse.json( + { success: false, user: null, error: 'Identifier and password are required' }, + { status: 400 }, + ) + } + + const result = await login(identifier, password) + + if (!result.success || result.user === null) { + return NextResponse.json(result, { status: 401 }) + } + + // Create a session token and persist it + const sessionToken = crypto.randomUUID() + await db.sessions.create({ + id: `sess_${sessionToken}`, + userId: result.user.id ?? '', + token: sessionToken, + expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, + }) + + // Set session cookie + const cookieStore = await cookies() + cookieStore.set('session_token', sessionToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 7 * 24 * 60 * 60, + }) + + return NextResponse.json(result) + } catch (error) { + console.error('Login route error:', error) + return NextResponse.json( + { success: false, user: null, error: 'Internal server error' }, + { status: 500 }, + ) + } +} diff --git a/frontends/nextjs/src/app/api/auth/logout/route.ts b/frontends/nextjs/src/app/api/auth/logout/route.ts new file mode 100644 index 000000000..3fa34dcde --- /dev/null +++ b/frontends/nextjs/src/app/api/auth/logout/route.ts @@ -0,0 +1,33 @@ +/** + * POST /api/auth/logout + * + * Clears the session cookie and invalidates the session. + */ + +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { db } from '@/lib/db-client' + +export async function POST(): Promise { + try { + const cookieStore = await cookies() + const sessionToken = cookieStore.get('session_token')?.value + + // Remove session from DBAL if it exists + if (sessionToken !== undefined && sessionToken.length > 0) { + const sessions = await db.sessions.list({ filter: { token: sessionToken } }) + const session = sessions.data[0] as { id: string } | undefined + if (session !== undefined) { + await db.sessions.remove(session.id) + } + } + + // Clear cookie + cookieStore.delete('session_token') + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Logout route error:', error) + return NextResponse.json({ success: true }) + } +} diff --git a/frontends/nextjs/src/app/api/auth/register/route.ts b/frontends/nextjs/src/app/api/auth/register/route.ts new file mode 100644 index 000000000..b3630d818 --- /dev/null +++ b/frontends/nextjs/src/app/api/auth/register/route.ts @@ -0,0 +1,62 @@ +/** + * POST /api/auth/register + * + * Creates a new user account and sets a session cookie. + */ + +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { register } from '@/lib/auth/api/register' +import { db } from '@/lib/db-client' +import crypto from 'crypto' + +export async function POST(request: Request): Promise { + try { + const body = await request.json() as { + username?: string + email?: string + password?: string + } + const { username, email, password } = body + + if (username === undefined || email === undefined || password === undefined) { + return NextResponse.json( + { success: false, user: null, error: 'Username, email, and password are required' }, + { status: 400 }, + ) + } + + const result = await register(username, email, password) + + if (!result.success || result.user === null) { + return NextResponse.json(result, { status: 400 }) + } + + // Create a session token and persist it + const sessionToken = crypto.randomUUID() + await db.sessions.create({ + id: `sess_${sessionToken}`, + userId: result.user.id ?? '', + token: sessionToken, + expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, + }) + + // Set session cookie + const cookieStore = await cookies() + cookieStore.set('session_token', sessionToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 7 * 24 * 60 * 60, + }) + + return NextResponse.json(result) + } catch (error) { + console.error('Register route error:', error) + return NextResponse.json( + { success: false, user: null, error: 'Internal server error' }, + { status: 500 }, + ) + } +} diff --git a/frontends/nextjs/src/app/api/auth/session/route.ts b/frontends/nextjs/src/app/api/auth/session/route.ts new file mode 100644 index 000000000..a5c6ab99f --- /dev/null +++ b/frontends/nextjs/src/app/api/auth/session/route.ts @@ -0,0 +1,23 @@ +/** + * GET /api/auth/session + * + * Returns the current authenticated user from the session cookie. + */ + +import { NextResponse } from 'next/server' +import { fetchSession } from '@/lib/auth/api/fetch-session' + +export async function GET(): Promise { + try { + const user = await fetchSession() + + if (user === null) { + return NextResponse.json({ user: null }, { status: 401 }) + } + + return NextResponse.json({ user }) + } catch (error) { + console.error('Session route error:', error) + return NextResponse.json({ user: null }, { status: 500 }) + } +} diff --git a/frontends/nextjs/src/hooks/auth/auth-store.ts b/frontends/nextjs/src/hooks/auth/auth-store.ts index 2cc4e85c6..be57c542c 100644 --- a/frontends/nextjs/src/hooks/auth/auth-store.ts +++ b/frontends/nextjs/src/hooks/auth/auth-store.ts @@ -1,15 +1,21 @@ /** * @file auth-store.ts * @description Authentication state management store + * + * All auth operations go through API routes to avoid importing + * server-only modules into the client bundle. */ -import { fetchSession } from '@/lib/auth/api/fetch-session' -import { login as loginRequest } from '@/lib/auth/api/login' -import { logout as logoutRequest } from '@/lib/auth/api/logout' -import { register as registerRequest } from '@/lib/auth/api/register' - +import type { User } from '@/lib/types/level-types' import type { AuthState } from './auth-types' import { mapUserToAuthUser } from './utils/map-user' +import { BASE_PATH } from '@/lib/app-config' + +interface AuthApiResponse { + success: boolean + user: User | null + error?: string +} export class AuthStore { private state: AuthState = { @@ -51,8 +57,15 @@ export class AuthStore { }) try { - const result = await loginRequest(identifier, password) - + const response = await fetch(`${BASE_PATH}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ identifier, password }), + }) + + const result = await response.json() as AuthApiResponse + if (!result.success || result.user === null) { this.setState({ ...this.state, @@ -60,7 +73,7 @@ export class AuthStore { }) throw new Error(result.error ?? 'Login failed') } - + this.setState({ user: mapUserToAuthUser(result.user), isAuthenticated: true, @@ -82,8 +95,15 @@ export class AuthStore { }) try { - const result = await registerRequest(username, email, password) - + const response = await fetch(`${BASE_PATH}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ username, email, password }), + }) + + const result = await response.json() as AuthApiResponse + if (!result.success || result.user === null) { this.setState({ ...this.state, @@ -91,7 +111,7 @@ export class AuthStore { }) throw new Error(result.error ?? 'Registration failed') } - + this.setState({ user: mapUserToAuthUser(result.user), isAuthenticated: true, @@ -108,7 +128,10 @@ export class AuthStore { async logout(): Promise { try { - await logoutRequest() + await fetch(`${BASE_PATH}/api/auth/logout`, { + method: 'POST', + credentials: 'include', + }) } finally { this.setState({ user: null, @@ -125,10 +148,15 @@ export class AuthStore { }) try { - const user = await fetchSession() - if (user !== null && user !== undefined) { + const response = await fetch(`${BASE_PATH}/api/auth/session`, { + credentials: 'include', + }) + + const result = await response.json() as { user: User | null } + + if (result.user !== null && result.user !== undefined) { this.setState({ - user: mapUserToAuthUser(user), + user: mapUserToAuthUser(result.user), isAuthenticated: true, isLoading: false, })