27 KiB
API Integration Guide - Frontend
Document Overview
This guide provides complete documentation for integrating ColaFlow's frontend with the .NET 9 backend API, including all endpoints, request/response formats, error handling, and best practices.
Base URL: http://localhost:5000/api (Development)
Production URL: https://api.colaflow.com/api
Table of Contents
- API Client Configuration
- Authentication APIs
- Tenant Management APIs
- MCP Token APIs
- Error Handling
- Rate Limiting
- Testing with Mock Data
API Client Configuration
Axios Instance Setup
File: lib/api-client.ts
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
import { useAuthStore } from '@/stores/useAuthStore';
const apiClient: AxiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // Important: Send httpOnly cookies
});
// Request Interceptor: Add Authorization header
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const { accessToken, tenant } = useAuthStore.getState();
// Add Bearer token
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
// Add tenant ID header (optional, for non-browser clients)
if (tenant && !config.headers['X-Tenant-Id']) {
config.headers['X-Tenant-Id'] = tenant.id;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response Interceptor: Handle token refresh
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void;
}> = [];
const processQueue = (error: AxiosError | null, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// If 401 error and not already retrying
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// If refresh is in progress, queue this request
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(() => {
return apiClient(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// Call refresh token endpoint
const response = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`,
{},
{ withCredentials: true }
);
const { accessToken } = response.data;
// Update token in store
useAuthStore.getState().setAccessToken(accessToken);
// Update Authorization header
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
// Process queued requests
processQueue(null, accessToken);
// Retry original request
return apiClient(originalRequest);
} catch (refreshError) {
// Refresh failed, logout user
processQueue(refreshError as AxiosError, null);
useAuthStore.getState().clearAuth();
// Redirect to login
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default apiClient;
Authentication APIs
1. Local Login
Endpoint: POST /api/auth/login
Request:
interface LoginRequest {
email: string;
password: string;
rememberMe?: boolean;
}
Example:
{
"email": "admin@acme.com",
"password": "SecurePassword123!",
"rememberMe": true
}
Response (200 OK):
interface LoginResponse {
user: {
id: string;
email: string;
fullName: string;
role: 'Admin' | 'Member' | 'Viewer';
authProvider: 'Local' | 'AzureAD' | 'Google' | 'Okta';
avatarUrl?: string;
};
tenant: {
id: string;
slug: string;
name: string;
plan: 'Free' | 'Starter' | 'Professional' | 'Enterprise';
status: 'Active' | 'Trial' | 'Suspended' | 'Cancelled';
ssoEnabled: boolean;
};
accessToken: string;
// Refresh token is set as httpOnly cookie automatically
}
Example:
{
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "admin@acme.com",
"fullName": "John Doe",
"role": "Admin",
"authProvider": "Local",
"avatarUrl": null
},
"tenant": {
"id": "660e8400-e29b-41d4-a716-446655440001",
"slug": "acme",
"name": "Acme Corporation",
"plan": "Professional",
"status": "Active",
"ssoEnabled": true
},
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Error Responses:
400 Bad Request: Invalid input (email/password missing)401 Unauthorized: Invalid credentials403 Forbidden: Account suspended or disabled404 Not Found: Tenant not found
2. SSO Login Initiation
Endpoint: GET /api/auth/sso/login?provider={provider}&redirect={redirect}
Query Parameters:
provider(required):azuread|google|okta|samlredirect(optional): URL to redirect after successful login (default:/)
Example:
GET /api/auth/sso/login?provider=azuread&redirect=/dashboard
Response (302 Redirect): Redirects to IdP login page with state parameter.
Frontend Implementation:
// services/auth.service.ts
export const authService = {
loginWithSso: (provider: 'azuread' | 'google' | 'okta' | 'saml', redirectUrl?: string) => {
const params = new URLSearchParams({ provider });
if (redirectUrl) params.append('redirect', redirectUrl);
// Backend will redirect, so we navigate directly
window.location.href = `/api/auth/sso/login?${params.toString()}`;
},
};
3. SSO Callback
Endpoint: GET /api/auth/sso/callback?code={code}&state={state}
Query Parameters:
code(required): Authorization code from IdPstate(required): CSRF protection state
Response (302 Redirect): Redirects to frontend callback page with token:
https://app.colaflow.com/auth/callback?token={accessToken}&tenant={tenantSlug}
Frontend Callback Handler:
File: app/(auth)/auth/callback/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuthStore } from '@/stores/useAuthStore';
export default function SsoCallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [error, setError] = useState<string | null>(null);
const setUser = useAuthStore((state) => state.setUser);
useEffect(() => {
const handleCallback = async () => {
try {
// Get token from URL
const token = searchParams.get('token');
const tenantSlug = searchParams.get('tenant');
if (!token || !tenantSlug) {
throw new Error('Missing token or tenant in callback');
}
// Validate state parameter (if frontend initiated SSO)
const state = searchParams.get('state');
const storedState = sessionStorage.getItem('sso_state');
if (storedState && state !== storedState) {
throw new Error('Invalid state parameter (CSRF protection)');
}
// Clear stored state
sessionStorage.removeItem('sso_state');
// Decode JWT to get user info (or call /api/auth/me)
// For now, we'll call /api/auth/me with the token
const response = await fetch('/api/auth/me', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch user info');
}
const data = await response.json();
// Store in AuthStore
setUser(data.user, data.tenant, token);
// Redirect to original page or dashboard
const redirectUrl = sessionStorage.getItem('redirect_after_login') || '/dashboard';
sessionStorage.removeItem('redirect_after_login');
router.push(redirectUrl);
} catch (err: any) {
console.error('SSO callback error:', err);
setError(err.message || 'SSO authentication failed');
}
};
handleCallback();
}, [searchParams, router, setUser]);
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
<h2 className="text-lg font-semibold text-red-800 mb-2">
Authentication Failed
</h2>
<p className="text-red-700 mb-4">{error}</p>
<button
onClick={() => router.push('/login')}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Back to Login
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4" />
<p className="text-gray-600">Completing sign-in...</p>
</div>
</div>
);
}
4. Logout
Endpoint: POST /api/auth/logout
Request: Empty body
Response (200 OK):
{
"message": "Logged out successfully"
}
Frontend Implementation:
// hooks/auth/useLogout.ts
import { useMutation } from '@tanstack/react-query';
import { authService } from '@/services/auth.service';
import { useAuthStore } from '@/stores/useAuthStore';
import { useRouter } from 'next/navigation';
export function useLogout() {
const clearAuth = useAuthStore((state) => state.clearAuth);
const router = useRouter();
return useMutation({
mutationFn: authService.logout,
onSuccess: () => {
clearAuth();
router.push('/login');
},
});
}
5. Refresh Token
Endpoint: POST /api/auth/refresh
Request: Empty body (uses httpOnly cookie)
Response (200 OK):
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Error Responses:
401 Unauthorized: Refresh token expired or invalid
Frontend Implementation:
Handled automatically by Axios interceptor (see API Client Configuration)
6. Get Current User
Endpoint: GET /api/auth/me
Headers:
Authorization: Bearer {accessToken}
Response (200 OK):
{
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "admin@acme.com",
"fullName": "John Doe",
"role": "Admin",
"authProvider": "AzureAD",
"avatarUrl": "https://graph.microsoft.com/v1.0/me/photo/$value"
},
"tenant": {
"id": "660e8400-e29b-41d4-a716-446655440001",
"slug": "acme",
"name": "Acme Corporation",
"plan": "Professional",
"status": "Active",
"ssoEnabled": true
}
}
Tenant Management APIs
1. Register Tenant (Signup)
Endpoint: POST /api/tenants/register
Request:
interface RegisterTenantRequest {
// Organization info
organizationName: string;
slug: string;
// Admin user info
adminEmail: string;
adminPassword: string;
adminFullName: string;
// Subscription
plan: 'Free' | 'Starter' | 'Professional' | 'Enterprise';
}
Example:
{
"organizationName": "Acme Corporation",
"slug": "acme",
"adminEmail": "admin@acme.com",
"adminPassword": "SecurePassword123!",
"adminFullName": "John Doe",
"plan": "Professional"
}
Response (201 Created):
{
"tenant": {
"id": "660e8400-e29b-41d4-a716-446655440001",
"slug": "acme",
"name": "Acme Corporation",
"plan": "Professional",
"status": "Active"
},
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "admin@acme.com",
"fullName": "John Doe",
"role": "Admin"
},
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Error Responses:
400 Bad Request: Validation errors (slug format, password strength)409 Conflict: Slug already taken
2. Check Slug Availability
Endpoint: GET /api/tenants/check-slug?slug={slug}
Query Parameters:
slug(required): Tenant slug to check
Example:
GET /api/tenants/check-slug?slug=acme
Response (200 OK):
{
"slug": "acme",
"available": false
}
Frontend Implementation (with debounce):
// hooks/tenants/useCheckSlug.ts
import { useQuery } from '@tanstack/react-query';
import { tenantService } from '@/services/tenant.service';
import { useMemo } from 'react';
import debounce from 'lodash-es/debounce';
export function useCheckSlug(slug: string) {
// Debounce slug changes
const debouncedSlug = useMemo(
() => debounce((value: string) => value, 500),
[]
);
const trimmedSlug = slug.trim().toLowerCase();
return useQuery({
queryKey: ['check-slug', trimmedSlug],
queryFn: () => tenantService.checkSlugAvailability(trimmedSlug),
enabled: trimmedSlug.length >= 3, // Only check if at least 3 chars
staleTime: 5000, // Cache for 5 seconds
});
}
3. Get SSO Configuration
Endpoint: GET /api/tenants/{tenantId}/sso
Headers:
Authorization: Bearer {accessToken}
Response (200 OK):
{
"enabled": true,
"provider": "AzureAD",
"authority": "https://login.microsoftonline.com/tenant-id",
"clientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"metadataUrl": "https://login.microsoftonline.com/tenant-id/v2.0/.well-known/openid-configuration",
"autoProvisionUsers": true,
"allowedDomains": ["acme.com", "acme.org"]
}
Note: clientSecret is never returned in API responses.
4. Update SSO Configuration
Endpoint: PUT /api/tenants/{tenantId}/sso
Headers:
Authorization: Bearer {accessToken}
Request:
interface UpdateSsoConfigRequest {
provider: 'AzureAD' | 'Google' | 'Okta' | 'GenericSaml';
// OIDC fields (for AzureAD, Google, Okta)
authority?: string;
clientId?: string;
clientSecret?: string;
metadataUrl?: string;
// SAML fields (for GenericSaml)
entityId?: string;
signOnUrl?: string;
certificate?: string;
// Common fields
autoProvisionUsers?: boolean;
allowedDomains?: string[];
}
Example (Azure AD):
{
"provider": "AzureAD",
"authority": "https://login.microsoftonline.com/tenant-id",
"clientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"clientSecret": "client-secret-here",
"metadataUrl": "https://login.microsoftonline.com/tenant-id/v2.0/.well-known/openid-configuration",
"autoProvisionUsers": true,
"allowedDomains": ["acme.com"]
}
Response (200 OK):
{
"message": "SSO configuration updated successfully"
}
Error Responses:
400 Bad Request: Validation errors (missing fields, invalid URLs)403 Forbidden: Only Admin users can update SSO config422 Unprocessable Entity: SSO not available for Free plan
5. Test SSO Connection
Endpoint: POST /api/tenants/{tenantId}/sso/test
Headers:
Authorization: Bearer {accessToken}
Response (200 OK):
{
"success": true,
"message": "SSO connection successful"
}
Response (200 OK) - Failed Test:
{
"success": false,
"message": "Failed to connect to IdP: Connection timeout"
}
MCP Token APIs
1. List MCP Tokens
Endpoint: GET /api/mcp/tokens
Headers:
Authorization: Bearer {accessToken}
Response (200 OK):
{
"tokens": [
{
"id": "770e8400-e29b-41d4-a716-446655440002",
"name": "Claude AI Agent",
"permissions": {
"projects": ["read", "search"],
"issues": ["read", "create", "update", "search"],
"documents": ["read", "search"]
},
"status": "Active",
"createdAt": "2025-11-01T10:00:00Z",
"lastUsedAt": "2025-11-03T14:30:00Z",
"expiresAt": "2026-11-01T10:00:00Z",
"usageCount": 1234
}
]
}
2. Create MCP Token
Endpoint: POST /api/mcp/tokens
Headers:
Authorization: Bearer {accessToken}
Request:
interface CreateMcpTokenRequest {
name: string;
permissions: {
[resource: string]: string[];
};
expiresAt?: string; // ISO 8601 date
ipWhitelist?: string[];
}
Example:
{
"name": "Claude AI Agent",
"permissions": {
"projects": ["read", "search"],
"issues": ["read", "create", "update", "search"],
"documents": ["read", "create", "search"],
"reports": ["read"]
},
"expiresAt": "2026-11-01T00:00:00Z",
"ipWhitelist": ["192.168.1.100", "10.0.0.0/24"]
}
Response (201 Created):
{
"tokenId": "770e8400-e29b-41d4-a716-446655440002",
"token": "mcp_acme_7f3d8a9c4e1b2f5a6d8c9e0f1a2b3c4d",
"name": "Claude AI Agent",
"createdAt": "2025-11-03T10:00:00Z",
"expiresAt": "2026-11-01T00:00:00Z"
}
IMPORTANT: The token field is shown only once. It will never be returned again.
Error Responses:
400 Bad Request: Invalid permissions or validation errors403 Forbidden: Only Admin users can create MCP tokens422 Unprocessable Entity: Invalid resource or operation names
3. Get MCP Token Details
Endpoint: GET /api/mcp/tokens/{tokenId}
Headers:
Authorization: Bearer {accessToken}
Response (200 OK):
{
"id": "770e8400-e29b-41d4-a716-446655440002",
"name": "Claude AI Agent",
"permissions": {
"projects": ["read", "search"],
"issues": ["read", "create", "update", "search"]
},
"status": "Active",
"createdAt": "2025-11-01T10:00:00Z",
"lastUsedAt": "2025-11-03T14:30:00Z",
"expiresAt": "2026-11-01T10:00:00Z",
"usageCount": 1234,
"ipWhitelist": ["192.168.1.100"]
}
4. Revoke MCP Token
Endpoint: DELETE /api/mcp/tokens/{tokenId}
Headers:
Authorization: Bearer {accessToken}
Request Body (Optional):
{
"reason": "No longer needed"
}
Response (200 OK):
{
"message": "Token revoked successfully"
}
5. Get MCP Token Audit Logs
Endpoint: GET /api/mcp/tokens/{tokenId}/audit-logs
Headers:
Authorization: Bearer {accessToken}
Query Parameters:
page(optional): Page number (default: 1)limit(optional): Results per page (default: 50, max: 100)startDate(optional): Filter by date range (ISO 8601)endDate(optional): Filter by date range (ISO 8601)statusCode(optional): Filter by HTTP status code (e.g., 200, 401, 403)
Example:
GET /api/mcp/tokens/{tokenId}/audit-logs?page=1&limit=50&statusCode=200
Response (200 OK):
{
"logs": [
{
"id": "880e8400-e29b-41d4-a716-446655440003",
"timestamp": "2025-11-03T14:30:15Z",
"httpMethod": "GET",
"endpoint": "/api/mcp/issues/search",
"statusCode": 200,
"durationMs": 125,
"ipAddress": "192.168.1.100",
"userAgent": "Claude-MCP-Client/1.0"
},
{
"id": "880e8400-e29b-41d4-a716-446655440004",
"timestamp": "2025-11-03T14:28:42Z",
"httpMethod": "POST",
"endpoint": "/api/mcp/issues",
"statusCode": 201,
"durationMs": 342,
"ipAddress": "192.168.1.100",
"userAgent": "Claude-MCP-Client/1.0"
}
],
"pagination": {
"page": 1,
"limit": 50,
"totalPages": 5,
"totalCount": 234
}
}
Error Handling
Standard Error Response Format
interface ApiError {
error: string;
message: string;
statusCode: number;
timestamp: string;
details?: Record<string, string[]>; // Validation errors
}
Example (400 Bad Request):
{
"error": "Validation Error",
"message": "Invalid input data",
"statusCode": 400,
"timestamp": "2025-11-03T10:00:00Z",
"details": {
"email": ["Email is required", "Invalid email format"],
"password": ["Password must be at least 8 characters"]
}
}
HTTP Status Codes
| Code | Meaning | Example |
|---|---|---|
200 |
Success | Login successful |
201 |
Created | Tenant registered |
204 |
No Content | Token revoked |
400 |
Bad Request | Invalid input data |
401 |
Unauthorized | Invalid credentials or expired token |
403 |
Forbidden | Insufficient permissions |
404 |
Not Found | Tenant not found |
409 |
Conflict | Slug already taken |
422 |
Unprocessable Entity | Business logic error (e.g., SSO not available for Free plan) |
429 |
Too Many Requests | Rate limit exceeded |
500 |
Internal Server Error | Server error |
Frontend Error Handling
Global Error Handler:
// lib/api-client.ts (add to interceptor)
apiClient.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiError>) => {
// Handle different error types
if (error.response) {
const { status, data } = error.response;
switch (status) {
case 400:
// Show validation errors
if (data.details) {
Object.entries(data.details).forEach(([field, errors]) => {
errors.forEach((err) => {
toast.error(`${field}: ${err}`);
});
});
} else {
toast.error(data.message || 'Invalid input');
}
break;
case 401:
// Handled by token refresh interceptor
break;
case 403:
toast.error('You do not have permission to perform this action');
break;
case 404:
toast.error('Resource not found');
break;
case 409:
toast.error(data.message || 'Conflict: Resource already exists');
break;
case 422:
toast.error(data.message || 'Business logic error');
break;
case 429:
toast.error('Too many requests. Please try again later.');
break;
case 500:
toast.error('Server error. Please try again later.');
console.error('Server error:', data);
break;
default:
toast.error('An unexpected error occurred');
}
} else if (error.request) {
// Network error (no response)
toast.error('Network error. Please check your connection.');
} else {
// Other errors
toast.error('An unexpected error occurred');
}
return Promise.reject(error);
}
);
Rate Limiting
Rate Limit Headers
Backend includes rate limit information in response headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1699000000
Frontend Rate Limit Handling
// lib/api-client.ts
apiClient.interceptors.response.use(
(response) => {
// Check rate limit headers
const limit = response.headers['x-ratelimit-limit'];
const remaining = response.headers['x-ratelimit-remaining'];
const reset = response.headers['x-ratelimit-reset'];
if (remaining && parseInt(remaining) < 10) {
console.warn(`Rate limit warning: ${remaining}/${limit} requests remaining`);
}
return response;
},
(error) => {
if (error.response?.status === 429) {
const reset = error.response.headers['x-ratelimit-reset'];
const resetDate = new Date(parseInt(reset) * 1000);
toast.error(`Rate limit exceeded. Try again at ${resetDate.toLocaleTimeString()}`);
}
return Promise.reject(error);
}
);
Testing with Mock Data
MSW (Mock Service Worker) Setup
File: mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
// Auth: Login
http.post('/api/auth/login', async ({ request }) => {
const body = await request.json();
if (body.email === 'admin@acme.com' && body.password === 'password') {
return HttpResponse.json({
user: {
id: '550e8400-e29b-41d4-a716-446655440000',
email: 'admin@acme.com',
fullName: 'John Doe',
role: 'Admin',
authProvider: 'Local',
},
tenant: {
id: '660e8400-e29b-41d4-a716-446655440001',
slug: 'acme',
name: 'Acme Corporation',
plan: 'Professional',
status: 'Active',
ssoEnabled: true,
},
accessToken: 'mock-access-token',
});
}
return HttpResponse.json(
{ error: 'Invalid credentials', message: 'Email or password is incorrect' },
{ status: 401 }
);
}),
// Tenants: Check Slug
http.get('/api/tenants/check-slug', ({ request }) => {
const url = new URL(request.url);
const slug = url.searchParams.get('slug');
const takenSlugs = ['acme', 'beta', 'test'];
return HttpResponse.json({
slug,
available: !takenSlugs.includes(slug || ''),
});
}),
// MCP: List Tokens
http.get('/api/mcp/tokens', () => {
return HttpResponse.json({
tokens: [
{
id: '770e8400-e29b-41d4-a716-446655440002',
name: 'Claude AI Agent',
permissions: {
projects: ['read', 'search'],
issues: ['read', 'create', 'update', 'search'],
},
status: 'Active',
createdAt: '2025-11-01T10:00:00Z',
lastUsedAt: '2025-11-03T14:30:00Z',
expiresAt: '2026-11-01T10:00:00Z',
usageCount: 1234,
},
],
});
}),
// MCP: Create Token
http.post('/api/mcp/tokens', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{
tokenId: '770e8400-e29b-41d4-a716-446655440002',
token: 'mcp_acme_7f3d8a9c4e1b2f5a6d8c9e0f1a2b3c4d',
name: body.name,
createdAt: new Date().toISOString(),
expiresAt: body.expiresAt,
},
{ status: 201 }
);
}),
];
Setup MSW:
// mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
Enable MSW in Development:
// app/layout.tsx
if (process.env.NODE_ENV === 'development') {
import('../mocks/browser').then(({ worker }) => {
worker.start();
});
}
Conclusion
This API integration guide provides all the information needed to integrate ColaFlow's frontend with the .NET 9 backend API. All endpoints are documented with complete request/response examples, error handling, and best practices.
Key Takeaways:
- ✅ Use Axios interceptors for automatic token injection and refresh
- ✅ Handle errors globally with consistent UI feedback
- ✅ Use TanStack Query for data fetching and caching
- ✅ Test with MSW before backend is ready
- ✅ Monitor rate limits to avoid 429 errors
Next Document: state-management-guide.md (Zustand + TanStack Query integration)