# 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 1. [API Client Configuration](#api-client-configuration) 2. [Authentication APIs](#authentication-apis) 3. [Tenant Management APIs](#tenant-management-apis) 4. [MCP Token APIs](#mcp-token-apis) 5. [Error Handling](#error-handling) 6. [Rate Limiting](#rate-limiting) 7. [Testing with Mock Data](#testing-with-mock-data) --- ## API Client Configuration ### Axios Instance Setup **File**: `lib/api-client.ts` ```typescript 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**: ```typescript interface LoginRequest { email: string; password: string; rememberMe?: boolean; } ``` **Example**: ```json { "email": "admin@acme.com", "password": "SecurePassword123!", "rememberMe": true } ``` **Response (200 OK)**: ```typescript 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**: ```json { "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 credentials - `403 Forbidden`: Account suspended or disabled - `404 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` | `saml` - `redirect` (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**: ```typescript // 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 IdP - `state` (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` ```typescript '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(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 (

Authentication Failed

{error}

); } return (

Completing sign-in...

); } ``` --- ### 4. Logout **Endpoint**: `POST /api/auth/logout` **Request**: Empty body **Response (200 OK)**: ```json { "message": "Logged out successfully" } ``` **Frontend Implementation**: ```typescript // 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)**: ```json { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` **Error Responses**: - `401 Unauthorized`: Refresh token expired or invalid **Frontend Implementation**: > Handled automatically by Axios interceptor (see [API Client Configuration](#api-client-configuration)) --- ### 6. Get Current User **Endpoint**: `GET /api/auth/me` **Headers**: ``` Authorization: Bearer {accessToken} ``` **Response (200 OK)**: ```json { "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**: ```typescript interface RegisterTenantRequest { // Organization info organizationName: string; slug: string; // Admin user info adminEmail: string; adminPassword: string; adminFullName: string; // Subscription plan: 'Free' | 'Starter' | 'Professional' | 'Enterprise'; } ``` **Example**: ```json { "organizationName": "Acme Corporation", "slug": "acme", "adminEmail": "admin@acme.com", "adminPassword": "SecurePassword123!", "adminFullName": "John Doe", "plan": "Professional" } ``` **Response (201 Created)**: ```json { "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)**: ```json { "slug": "acme", "available": false } ``` **Frontend Implementation** (with debounce): ```typescript // 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)**: ```json { "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**: ```typescript 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)**: ```json { "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)**: ```json { "message": "SSO configuration updated successfully" } ``` **Error Responses**: - `400 Bad Request`: Validation errors (missing fields, invalid URLs) - `403 Forbidden`: Only Admin users can update SSO config - `422 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)**: ```json { "success": true, "message": "SSO connection successful" } ``` **Response (200 OK) - Failed Test**: ```json { "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)**: ```json { "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**: ```typescript interface CreateMcpTokenRequest { name: string; permissions: { [resource: string]: string[]; }; expiresAt?: string; // ISO 8601 date ipWhitelist?: string[]; } ``` **Example**: ```json { "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)**: ```json { "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 errors - `403 Forbidden`: Only Admin users can create MCP tokens - `422 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)**: ```json { "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): ```json { "reason": "No longer needed" } ``` **Response (200 OK)**: ```json { "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)**: ```json { "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 ```typescript interface ApiError { error: string; message: string; statusCode: number; timestamp: string; details?: Record; // Validation errors } ``` **Example (400 Bad Request)**: ```json { "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**: ```typescript // lib/api-client.ts (add to interceptor) apiClient.interceptors.response.use( (response) => response, (error: AxiosError) => { // 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 ```typescript // 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` ```typescript 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**: ```typescript // mocks/browser.ts import { setupWorker } from 'msw/browser'; import { handlers } from './handlers'; export const worker = setupWorker(...handlers); ``` **Enable MSW in Development**: ```typescript // 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)