Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/frontend/src/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -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 <ChakraProvider value={defaultSystem}>{children}</ChakraProvider>;
return (
<ChakraProvider value={defaultSystem}>
<AuthProvider>{children}</AuthProvider>
</ChakraProvider>
);
}
177 changes: 177 additions & 0 deletions apps/frontend/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
register: (email: string, password: string, name: string) => Promise<void>;
verifyEmail: (email: string, code: string) => Promise<void>;
resendCode: (email: string) => Promise<void>;
logout: () => Promise<void>;
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<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(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<LoginResponse>('/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 (
<AuthContext.Provider
value={{
user,
isAuthenticated: user !== null,
isLoading,
login,
register,
verifyEmail,
resendCode,
logout,
getAccessToken,
}}
>
{children}
</AuthContext.Provider>
);
}

export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
return ctx;
}
26 changes: 26 additions & 0 deletions apps/frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
path: string,
{ token, headers, ...options }: RequestOptions = {},
): Promise<T> {
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<T>;
}
138 changes: 138 additions & 0 deletions apps/frontend/test/context/AuthContext.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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 }) => (
<AuthProvider>{children}</AuthProvider>
);

// ---------------------------------------------------------------------------
// 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);
});
});
Loading
Loading