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