diff --git a/apps/frontend/src/app/providers.tsx b/apps/frontend/src/app/providers.tsx index 62c6300..7605b20 100644 --- a/apps/frontend/src/app/providers.tsx +++ b/apps/frontend/src/app/providers.tsx @@ -1,7 +1,12 @@ 'use client'; import { ChakraProvider, defaultSystem } from '@chakra-ui/react'; +import { AuthProvider } from '@/context/AuthContext'; export function Providers({ children }: { children: React.ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/apps/frontend/src/context/AuthContext.tsx b/apps/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..eb57dbc --- /dev/null +++ b/apps/frontend/src/context/AuthContext.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { createContext, useContext, useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface User { + sub: string; + email: string; + name?: string; +} + +interface AuthTokens { + accessToken: string; + idToken: string; + refreshToken: string; +} + +interface AuthContextValue { + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string, name: string) => Promise; + verifyEmail: (email: string, code: string) => Promise; + resendCode: (email: string) => Promise; + logout: () => Promise; + getAccessToken: () => string | null; +} + +// --------------------------------------------------------------------------- +// Backend response shapes +// --------------------------------------------------------------------------- + +interface LoginResponse { + AccessToken: string; + IdToken: string; + RefreshToken: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const STORAGE_KEYS = { + ACCESS: 'branch_access_token', + ID: 'branch_id_token', + REFRESH: 'branch_refresh_token', +} as const; + +function decodeIdToken(token: string): User | null { + try { + const payload = token.split('.')[1]; + const padded = payload.replace(/-/g, '+').replace(/_/g, '/'); + const json = atob(padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), '=')); + const claims = JSON.parse(json); + return { + sub: claims.sub, + email: claims.email, + name: claims.name ?? claims['cognito:username'], + }; + } catch { + return null; + } +} + +function saveTokens({ accessToken, idToken, refreshToken }: AuthTokens) { + localStorage.setItem(STORAGE_KEYS.ACCESS, accessToken); + localStorage.setItem(STORAGE_KEYS.ID, idToken); + localStorage.setItem(STORAGE_KEYS.REFRESH, refreshToken); +} + +function clearTokens() { + localStorage.removeItem(STORAGE_KEYS.ACCESS); + localStorage.removeItem(STORAGE_KEYS.ID); + localStorage.removeItem(STORAGE_KEYS.REFRESH); +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Restore session from localStorage on mount + useEffect(() => { + const idToken = localStorage.getItem(STORAGE_KEYS.ID); + if (idToken) { + setUser(decodeIdToken(idToken)); + } + setIsLoading(false); + }, []); + + async function login(email: string, password: string) { + const data = await apiFetch('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); + const tokens: AuthTokens = { + accessToken: data.AccessToken, + idToken: data.IdToken, + refreshToken: data.RefreshToken, + }; + saveTokens(tokens); + setUser(decodeIdToken(tokens.idToken)); + } + + async function register(email: string, password: string, name: string) { + await apiFetch('/auth/register', { + method: 'POST', + body: JSON.stringify({ email, password, name }), + }); + } + + async function verifyEmail(email: string, code: string) { + await apiFetch('/auth/verify-email', { + method: 'POST', + body: JSON.stringify({ email, code }), + }); + } + + async function resendCode(email: string) { + await apiFetch('/auth/resend-code', { + method: 'POST', + body: JSON.stringify({ email }), + }); + } + + async function logout() { + const accessToken = localStorage.getItem(STORAGE_KEYS.ACCESS); + if (accessToken) { + await apiFetch('/auth/logout', { + method: 'POST', + token: accessToken, + }).catch(() => { + // Best-effort — clear locally even if the server call fails + }); + } + clearTokens(); + setUser(null); + } + + function getAccessToken() { + return localStorage.getItem(STORAGE_KEYS.ACCESS); + } + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used inside AuthProvider'); + return ctx; +} diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts new file mode 100644 index 0000000..8bd4628 --- /dev/null +++ b/apps/frontend/src/lib/api.ts @@ -0,0 +1,26 @@ +const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3006'; + +interface RequestOptions extends RequestInit { + token?: string; +} + +export async function apiFetch( + path: string, + { token, headers, ...options }: RequestOptions = {}, +): Promise { + const res = await fetch(`${BASE_URL}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message ?? res.statusText); + } + + return res.json() as Promise; +} diff --git a/apps/frontend/test/context/AuthContext.test.tsx b/apps/frontend/test/context/AuthContext.test.tsx new file mode 100644 index 0000000..7b7ac2f --- /dev/null +++ b/apps/frontend/test/context/AuthContext.test.tsx @@ -0,0 +1,138 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { AuthProvider, useAuth } from '@/context/AuthContext'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a minimal JWT whose payload contains the given claims. */ +function makeIdToken(claims: Record) { + const payload = btoa(JSON.stringify(claims)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + return `eyJhbGciOiJSUzI1NiJ9.${payload}.signature`; +} + +const TEST_TOKENS = { + AccessToken: 'test-access-token', + IdToken: makeIdToken({ sub: 'sub-123', email: 'jane@example.com', name: 'Jane' }), + RefreshToken: 'test-refresh-token', +}; + +function mockFetch(body: unknown, ok = true) { + global.fetch = jest.fn().mockResolvedValue({ + ok, + statusText: 'Unauthorized', + json: jest.fn().mockResolvedValue(body), + } as unknown as Response); +} + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeEach(() => localStorage.clear()); +afterEach(() => jest.restoreAllMocks()); + +describe('AuthProvider / useAuth', () => { + it('throws when used outside AuthProvider', () => { + // suppress expected console.error from React + jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => renderHook(() => useAuth())).toThrow('useAuth must be used inside AuthProvider'); + }); + + it('starts with no user and finishes loading', async () => { + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.user).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); + }); + + it('restores user from localStorage on mount', async () => { + localStorage.setItem('branch_id_token', TEST_TOKENS.IdToken); + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.user).toMatchObject({ sub: 'sub-123', email: 'jane@example.com', name: 'Jane' }); + expect(result.current.isAuthenticated).toBe(true); + }); + + it('login stores tokens and sets user state', async () => { + mockFetch(TEST_TOKENS); + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.login('jane@example.com', 'password123'); + }); + + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.user).toMatchObject({ email: 'jane@example.com' }); + expect(localStorage.getItem('branch_access_token')).toBe('test-access-token'); + expect(localStorage.getItem('branch_refresh_token')).toBe('test-refresh-token'); + }); + + it('getAccessToken returns the stored access token', async () => { + mockFetch(TEST_TOKENS); + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.login('jane@example.com', 'password123'); + }); + + expect(result.current.getAccessToken()).toBe('test-access-token'); + }); + + it('logout clears user state and localStorage', async () => { + localStorage.setItem('branch_access_token', 'test-access-token'); + localStorage.setItem('branch_id_token', TEST_TOKENS.IdToken); + localStorage.setItem('branch_refresh_token', 'test-refresh-token'); + mockFetch({ success: true }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + + await act(async () => { + await result.current.logout(); + }); + + expect(result.current.user).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); + expect(localStorage.getItem('branch_access_token')).toBeNull(); + }); + + it('logout still clears state even if the server call fails', async () => { + localStorage.setItem('branch_access_token', 'test-access-token'); + localStorage.setItem('branch_id_token', TEST_TOKENS.IdToken); + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + + await act(async () => { + await result.current.logout(); + }); + + expect(result.current.user).toBeNull(); + expect(localStorage.getItem('branch_access_token')).toBeNull(); + }); + + it('login throws on invalid credentials', async () => { + mockFetch({ message: 'Invalid credentials' }, false); + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await expect( + act(async () => { + await result.current.login('bad@example.com', 'wrong'); + }), + ).rejects.toThrow('Invalid credentials'); + + expect(result.current.isAuthenticated).toBe(false); + }); +}); diff --git a/apps/frontend/test/lib/api.test.ts b/apps/frontend/test/lib/api.test.ts new file mode 100644 index 0000000..b942212 --- /dev/null +++ b/apps/frontend/test/lib/api.test.ts @@ -0,0 +1,51 @@ +import { apiFetch } from '@/lib/api'; + +function mockFetch(body: unknown, ok = true, status = 200) { + global.fetch = jest.fn().mockResolvedValue({ + ok, + status, + statusText: 'Bad Request', + json: jest.fn().mockResolvedValue(body), + } as unknown as Response); +} + +afterEach(() => jest.restoreAllMocks()); + +describe('apiFetch', () => { + it('calls fetch with the base URL prepended to the path', async () => { + mockFetch({ id: 1 }); + await apiFetch('/auth/login', { method: 'POST', body: '{}' }); + const url = (global.fetch as jest.Mock).mock.calls[0][0] as string; + expect(url).toMatch(/\/auth\/login$/); + }); + + it('adds Authorization header when token is provided', async () => { + mockFetch({ ok: true }); + await apiFetch('/auth/logout', { method: 'POST', token: 'my-token' }); + const headers = (global.fetch as jest.Mock).mock.calls[0][1].headers as Record; + expect(headers['Authorization']).toBe('Bearer my-token'); + }); + + it('does not add Authorization header when no token is given', async () => { + mockFetch({ ok: true }); + await apiFetch('/auth/health'); + const headers = (global.fetch as jest.Mock).mock.calls[0][1].headers as Record; + expect(headers['Authorization']).toBeUndefined(); + }); + + it('returns parsed JSON on success', async () => { + mockFetch({ userId: 42 }); + const result = await apiFetch<{ userId: number }>('/users/me'); + expect(result).toEqual({ userId: 42 }); + }); + + it('throws with the message from the error body on non-ok response', async () => { + mockFetch({ message: 'Invalid credentials' }, false, 401); + await expect(apiFetch('/auth/login')).rejects.toThrow('Invalid credentials'); + }); + + it('falls back to statusText when error body has no message', async () => { + mockFetch({}, false, 400); + await expect(apiFetch('/auth/login')).rejects.toThrow('Bad Request'); + }); +});