diff --git a/frontend/src/actions/auth.ts b/frontend/src/actions/auth.ts new file mode 100644 index 0000000..b97694c --- /dev/null +++ b/frontend/src/actions/auth.ts @@ -0,0 +1,196 @@ +'use server'; + +import { clearAuthCookies, getAuthToken, getAuthUser, setAuthCookies } from '@/lib/cookies'; +import { LoginRequest, SignupRequest, User } from '@/types/user'; +import { BASE_URL } from '../../constants/constants'; + +export interface AuthActionResult { + success: boolean; + error?: string; + user?: User; +} + +export interface SessionResult { + user: User | null; + token: string | null; +} + +/** + * Server action for user signup + */ +export async function signupAction(data: SignupRequest): Promise { + try { + const res = await fetch(`${BASE_URL}/signup`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (!res.ok) { + const error = await res.json(); + return { + success: false, + error: error.error || 'Failed to sign up.', + }; + } + + await res.json(); + return { success: true }; + } catch (error: unknown) { + if (error instanceof TypeError) { + return { + success: false, + error: `Unable to connect to the server. Please make sure the backend is running on ${BASE_URL}`, + }; + } + return { + success: false, + error: error instanceof Error ? error.message : 'An unexpected error occurred', + }; + } +} + +/** + * Server action for user login + */ +export async function loginAction(data: LoginRequest): Promise { + try { + const res = await fetch(`${BASE_URL}/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (!res.ok) { + const error = await res.json(); + return { + success: false, + error: error.error || 'Failed to log in.', + }; + } + + const response = await res.json(); + const { token, user } = response; + + if (!token || !user) { + return { + success: false, + error: 'Invalid login response format. Please contact support.', + }; + } + + // Set auth cookies + await setAuthCookies(token, user); + + return { + success: true, + user, + }; + } catch (error: unknown) { + if (error instanceof TypeError) { + return { + success: false, + error: `Unable to connect to the server. Please make sure the backend is running on ${BASE_URL}`, + }; + } + return { + success: false, + error: error instanceof Error ? error.message : 'An unexpected error occurred', + }; + } +} + +/** + * Server action for user logout + */ +export async function logoutAction(): Promise { + await clearAuthCookies(); +} + +/** + * Server action to get current session + */ +export async function getSession(): Promise { + const token = await getAuthToken(); + + if (!token) { + return { user: null, token: null }; + } + + try { + // Validate token by fetching user profile + const res = await fetch(`${BASE_URL}/profile/`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + if (!res.ok) { + // Token is invalid, clear cookies + await clearAuthCookies(); + return { user: null, token: null }; + } + + const user = await res.json(); + + // Update user cookie with fresh data + await setAuthCookies(token, user); + + return { user, token }; + } catch (error) { + console.error('Error validating session:', error); + + // On network error, return cached user data if available + if (error instanceof TypeError) { + const cachedUser = await getAuthUser(); + if (cachedUser) { + return { user: cachedUser, token }; + } + } + + // Clear invalid session + await clearAuthCookies(); + return { user: null, token: null }; + } +} + +/** + * Server action to refresh user profile + */ +export async function refreshUserProfile(): Promise { + const token = await getAuthToken(); + + if (!token) { + return null; + } + + try { + const res = await fetch(`${BASE_URL}/profile/`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + if (!res.ok) { + return null; + } + + const user = await res.json(); + + // Update user cookie + await setAuthCookies(token, user); + + return user; + } catch (error) { + console.error('Error refreshing user profile:', error); + return null; + } +} diff --git a/frontend/src/app/login/LoginForm.tsx b/frontend/src/app/login/LoginForm.tsx new file mode 100644 index 0000000..5bfb490 --- /dev/null +++ b/frontend/src/app/login/LoginForm.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { AuthActionResult } from '@/actions/auth'; +import { LoginRequest } from '@/types/user'; +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; + +interface LoginFormProps { + loginAction: (data: LoginRequest) => Promise; +} + +export function LoginForm({ loginAction }: LoginFormProps) { + const router = useRouter(); + const [error, setError] = useState(''); + const [isPending, startTransition] = useTransition(); + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(''); + + const formData = new FormData(event.currentTarget); + const email = formData.get('email') as string; + const password = formData.get('password') as string; + + startTransition(async () => { + try { + const result = await loginAction({ email, password }); + + if (result.success) { + router.push('/'); + } else { + setError(result.error || 'Failed to log in. Please try again.'); + } + } catch { + setError('An unexpected error occurred. Please try again.'); + } + }); + } + + return ( + <> + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + +
+ {/* Email */} +
+ + +
+ + {/* Password */} +
+ + +
+ + {/* Submit Button */} + +
+ + ); +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 1fd13ef..95c0a83 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,51 +1,17 @@ -"use client"; - -import { useAuth } from '@/contexts/AuthContext'; +import { loginAction } from '@/actions/auth'; +import { cookies } from 'next/headers'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; - -export default function LoginPage() { - const router = useRouter(); - const { login, isLoggedIn, isLoading: isAuthLoading } = useAuth(); - - // Redirect to home if already logged in - useEffect(() => { - if (!isAuthLoading && isLoggedIn) { - router.replace('/'); - } - }, [isLoggedIn, isAuthLoading, router]); +import { redirect } from 'next/navigation'; +import { LoginForm } from './LoginForm'; - const [formData, setFormData] = useState({ - email: '', - password: '', - }); - const [error, setError] = useState(''); - const [isLoading, setIsLoading] = useState(false); +export default async function LoginPage() { + // Check if user is already logged in + const cookieStore = await cookies(); + const token = cookieStore.get('auth_token')?.value; - const handleChange = (e: React.ChangeEvent) => { - setFormData({ - ...formData, - [e.target.name]: e.target.value, - }); - setError(''); // Clear error when user types - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - setIsLoading(true); - - try { - await login(formData); - router.push('/'); // Redirect to home page on success - } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : 'Failed to log in. Please try again.'; - setError(errorMessage); - } finally { - setIsLoading(false); - } - }; + if (token) { + redirect('/'); + } return (
@@ -58,58 +24,8 @@ export default function LoginPage() {

Sign in to your account to continue

- {/* Error Message */} - {error && ( -
- {error} -
- )} - {/* Form */} -
- {/* Email */} -
- - -
- - {/* Password */} -
- - -
- - {/* Submit Button */} - -
+ {/* Divider */}
diff --git a/frontend/src/app/signup/SignupForm.tsx b/frontend/src/app/signup/SignupForm.tsx new file mode 100644 index 0000000..54d898a --- /dev/null +++ b/frontend/src/app/signup/SignupForm.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { AuthActionResult } from '@/actions/auth'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { SignupRequest } from '@/types/user'; +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; + +interface SignupFormProps { + signupAction: (data: SignupRequest) => Promise; +} + +export function SignupForm({ signupAction }: SignupFormProps) { + const router = useRouter(); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [isPending, startTransition] = useTransition(); + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(''); + setSuccess(''); + + const formData = new FormData(event.currentTarget); + const name = formData.get('name') as string; + const email = formData.get('email') as string; + const password = formData.get('password') as string; + + startTransition(async () => { + try { + const result = await signupAction({ name, email, password }); + + if (result.success) { + setSuccess('Account created successfully! Redirecting to login...'); + setTimeout(() => { + router.push('/login'); + }, 1500); + } else { + setError(result.error || 'Failed to sign up. Please try again.'); + } + } catch { + setError('An unexpected error occurred. Please try again.'); + } + }); + } + + return ( + <> + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Success Message */} + {success && ( +
+ {success} +
+ )} + +
+ {/* Name */} +
+ + +
+ + {/* Email */} +
+ + +
+ + {/* Password */} +
+ + +

Must be at least 6 characters

+
+ + {/* Submit Button */} + +
+ + ); +} diff --git a/frontend/src/app/signup/page.tsx b/frontend/src/app/signup/page.tsx index b117ab5..13f4b31 100644 --- a/frontend/src/app/signup/page.tsx +++ b/frontend/src/app/signup/page.tsx @@ -1,55 +1,17 @@ -"use client"; - -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { useAuth } from '@/contexts/AuthContext'; +import { signupAction } from '@/actions/auth'; +import { cookies } from 'next/headers'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; - -export default function SignupPage() { - const router = useRouter(); - const { signup, isLoggedIn, isLoading: isAuthLoading } = useAuth(); - - // Redirect to home if already logged in - useEffect(() => { - if (!isAuthLoading && isLoggedIn) { - router.replace('/'); - } - }, [isLoggedIn, isAuthLoading, router]); - - const [formData, setFormData] = useState({ - name: '', - email: '', - password: '', - }); - const [error, setError] = useState(''); - const [isLoading, setIsLoading] = useState(false); +import { redirect } from 'next/navigation'; +import { SignupForm } from './SignupForm'; - const handleChange = (e: React.ChangeEvent) => { - setFormData({ - ...formData, - [e.target.name]: e.target.value, - }); - setError(''); // Clear error when user types - }; +export default async function SignupPage() { + // Check if user is already logged in + const cookieStore = await cookies(); + const token = cookieStore.get('auth_token')?.value; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - setIsLoading(true); - - try { - await signup(formData); - // Redirect to home page on successful signup - router.push('/'); - } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : 'Failed to sign up. Please try again.'; - setError(errorMessage); - } finally { - setIsLoading(false); - } - }; + if (token) { + redirect('/'); + } return (
@@ -62,77 +24,8 @@ export default function SignupPage() {

Join us to start shopping

- {/* Error Message */} - {error && ( -
- {error} -
- )} - {/* Form */} -
- {/* Name */} -
- - -
- - {/* Email */} -
- - -
- - {/* Password */} -
- - -

Must be at least 6 characters

-
- - {/* Submit Button */} - -
+ {/* Divider */}
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 0f5245b..ec51a0d 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,6 +1,6 @@ "use client"; -import { login as apiLogin, signup as apiSignup, getUserProfile } from '@/lib/api/auth'; +import { getSession, loginAction, logoutAction, signupAction } from '@/actions/auth'; import { LoginRequest, SignupRequest, User } from '@/types/user'; import { createContext, ReactNode, useContext, useEffect, useState } from 'react'; @@ -11,97 +11,48 @@ interface AuthContextType { isLoading: boolean; login: (data: LoginRequest) => Promise; signup: (data: SignupRequest) => Promise; - logout: () => void; + logout: () => Promise; } const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: ReactNode }) { - // Start with null state to match SSR const [user, setUser] = useState(null); const [token, setToken] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [isHydrated, setIsHydrated] = useState(false); - // Hydrate from localStorage immediately after mount (before async validation) + // Load session from server on mount useEffect(() => { - if (typeof window === 'undefined') return; - - // Load cached data synchronously - const storedToken = localStorage.getItem('auth_token'); - const cachedUser = localStorage.getItem('auth_user'); - - if (storedToken && cachedUser) { + const loadSession = async () => { try { - setToken(storedToken); - setUser(JSON.parse(cachedUser)); + const session = await getSession(); + setUser(session.user); + setToken(session.token); } catch (error) { - console.error('[AuthContext] Error parsing cached user:', error); - } - } - - setIsHydrated(true); - }, []); - - // Validate token after hydration - useEffect(() => { - if (!isHydrated) return; - - const loadAuth = async () => { - try { - // Use the token from state, which was hydrated from localStorage - if (token) { - // Validate token by fetching user profile - const profile = await getUserProfile(token); - // If profile is successfully fetched, user and token are valid - // No need to set token again, it's already in state - setUser(profile); - // Cache user data for optimistic loading - localStorage.setItem('auth_user', JSON.stringify(profile)); - } else { - // No token in state, so no need to validate - setUser(null); - } - } catch (error: unknown) { - console.error('[AuthContext] Error loading auth:', error); - // Only clear token if it's an authentication error (401) - // Don't clear on network errors - const errorMessage = error instanceof Error ? error.message : ''; - if (errorMessage.includes('Unable to connect to the server')) { - // Keep the token in localStorage for retry - // The token state is already set from hydration, so no change needed - } else { - // Authentication error - clear the invalid token and cached user - localStorage.removeItem('auth_token'); - localStorage.removeItem('auth_user'); - setToken(null); - setUser(null); - } + console.error('[AuthContext] Error loading session:', error); + setUser(null); + setToken(null); } finally { setIsLoading(false); } }; - loadAuth(); - }, [isHydrated]); // Only run once after hydration + loadSession(); + }, []); const login = async (data: LoginRequest) => { try { - const response = await apiLogin(data); - const newToken = response.token; + const result = await loginAction(data); - // Store token - localStorage.setItem('auth_token', newToken); - setToken(newToken); + if (!result.success) { + throw new Error(result.error || 'Failed to log in.'); + } - // Set user from login response (no need to fetch profile) - if (response.user) { - setUser(response.user); - // Cache user data for optimistic loading on reload - localStorage.setItem('auth_user', JSON.stringify(response.user)); - } else { - console.error("User data not found in login response. Backend may need to be restarted."); - throw new Error("Invalid login response format. Please contact support."); + if (result.user) { + setUser(result.user); + // Fetch the session to get the token + const session = await getSession(); + setToken(session.token); } } catch (error) { throw error; @@ -110,18 +61,28 @@ export function AuthProvider({ children }: { children: ReactNode }) { const signup = async (data: SignupRequest) => { try { - await apiSignup(data); + const result = await signupAction(data); + + if (!result.success) { + throw new Error(result.error || 'Failed to sign up.'); + } // Don't auto-login after signup, let user login manually } catch (error) { throw error; } }; - const logout = () => { - localStorage.removeItem('auth_token'); - localStorage.removeItem('auth_user'); - setToken(null); - setUser(null); + const logout = async () => { + try { + await logoutAction(); + setToken(null); + setUser(null); + } catch (error) { + console.error('[AuthContext] Error during logout:', error); + // Still clear local state even if server action fails + setToken(null); + setUser(null); + } }; const value: AuthContextType = { diff --git a/frontend/src/lib/cookies.ts b/frontend/src/lib/cookies.ts new file mode 100644 index 0000000..5b81279 --- /dev/null +++ b/frontend/src/lib/cookies.ts @@ -0,0 +1,49 @@ +import { User } from '@/types/user'; +import { cookies } from 'next/headers'; + +const TOKEN_COOKIE_NAME = 'auth_token'; +const USER_COOKIE_NAME = 'auth_user'; + +// Cookie options +const COOKIE_OPTIONS = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + maxAge: 60 * 60 * 24 * 7, // 7 days + path: '/', +}; + +export async function setAuthCookies(token: string, user: User) { + const cookieStore = await cookies(); + + cookieStore.set(TOKEN_COOKIE_NAME, token, COOKIE_OPTIONS); + cookieStore.set(USER_COOKIE_NAME, JSON.stringify(user), { + ...COOKIE_OPTIONS, + httpOnly: false, // Allow client to read user data + }); +} + +export async function getAuthToken(): Promise { + const cookieStore = await cookies(); + return cookieStore.get(TOKEN_COOKIE_NAME)?.value; +} + +export async function getAuthUser(): Promise { + const cookieStore = await cookies(); + const userCookie = cookieStore.get(USER_COOKIE_NAME)?.value; + + if (!userCookie) return null; + + try { + return JSON.parse(userCookie) as User; + } catch { + return null; + } +} + +export async function clearAuthCookies() { + const cookieStore = await cookies(); + + cookieStore.delete(TOKEN_COOKIE_NAME); + cookieStore.delete(USER_COOKIE_NAME); +} diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts new file mode 100644 index 0000000..f4ff09c --- /dev/null +++ b/frontend/src/middleware.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export function middleware(request: NextRequest) { + const token = request.cookies.get('auth_token')?.value; + const { pathname } = request.nextUrl; + + // Define protected routes + const protectedRoutes = ['/profile', '/cart']; + const authRoutes = ['/login', '/signup']; + + // Check if the current path is a protected route + const isProtectedRoute = protectedRoutes.some(route => pathname.startsWith(route)); + const isAuthRoute = authRoutes.some(route => pathname.startsWith(route)); + + // Redirect to login if accessing protected route without token + if (isProtectedRoute && !token) { + const url = new URL('/login', request.url); + url.searchParams.set('redirect', pathname); + return NextResponse.redirect(url); + } + + // Redirect to home if accessing auth routes with token + if (isAuthRoute && token) { + return NextResponse.redirect(new URL('/', request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + */ + '/((?!api|_next/static|_next/image|favicon.ico).*)', + ], +};