Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | 414x 413x 400x 13x 13x 13x 13x 13x 11x 107x 3x 107x 1x 1x 107x 414x 414x 414x 414x 1x 413x 413x 1x 414x 107x 59x 227x 227x 29x 29x 5x 5x 5x 1x 4x 4x 5x 3x 1x 1x 1x 1x 1x 1x 2x 3x 8x 8x 13x 198x 227x | /**
* API Client Configuration
* Story 1.2.1: HTTP Client with i18n Support
*/
import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import i18n from '@/i18n/config';
import { hasCognitoSession } from '@/utils/auth/cognitoSession';
import { ensureAmplifyConfigured } from '@/config/amplify';
import { authService } from '@/services/auth/authService';
import { setLogoutReason } from '@/services/auth/logoutReason';
/**
* Generate a unique correlation ID for request tracing
* @returns UUID v4 format correlation ID
*/
function generateCorrelationId(): string {
return crypto.randomUUID();
}
/**
* Get ID token from AWS Amplify (secure token management)
* Uses ID token (not access token) to include custom Cognito attributes (custom:role)
* @returns ID token string or null if not authenticated
*/
async function getIdToken(): Promise<string | null> {
// Anonymous visitors (public homepage / archive / event discovery) have no Cognito
// tokens in storage. Short-circuit BEFORE importing aws-amplify so the ~426 KB dependency
// never loads for them (perf/public-homepage-followup #2). Authenticated requests fall
// through to lazily configure + query Amplify.
if (!hasCognitoSession()) {
return null;
}
try {
await ensureAmplifyConfigured();
const { fetchAuthSession } = await import('aws-amplify/auth');
const session = await fetchAuthSession();
return session.tokens?.idToken?.toString() || null;
} catch {
return null;
}
}
/**
* Navigation callback for handling redirects from API interceptor
* Set this from App.tsx after router initialization
*/
let navigateCallback: ((path: string) => void) | null = null;
/**
* Set the navigation callback for API client redirects
* Call this from App.tsx: setNavigationCallback(navigate)
*/
export function setNavigationCallback(navigate: (path: string) => void): void {
navigateCallback = navigate;
}
/**
* Create axios instance with default configuration
*
* Note: baseURL is set to '/api' initially, but will be updated
* with runtime config from backend via updateApiClientConfig()
*/
const apiClient: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
// Configure params serialization for Spring compatibility
// Arrays are sent as repeated parameters: status=CONFIRMED&status=REGISTERED
// instead of bracket notation: status[]=CONFIRMED&status[]=REGISTERED
paramsSerializer: {
indexes: null, // This removes brackets from array parameters
},
});
/**
* Update API client configuration with runtime config from backend
* Called after runtime config is loaded in main.tsx
*
* @param apiBaseUrl - Base URL from runtime config (e.g., "http://localhost:8080/api/v1")
*/
export function updateApiClientConfig(apiBaseUrl: string): void {
apiClient.defaults.baseURL = apiBaseUrl;
console.log(`[ApiClient] Updated baseURL to: ${apiBaseUrl}`);
}
/**
* Request interceptor to add headers (Accept-Language, X-Correlation-ID, Authorization)
*/
apiClient.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
// Add Accept-Language header based on current i18n language
config.headers['Accept-Language'] = i18n.language;
// Add X-Correlation-ID for distributed tracing (Story 1.17 AC6)
config.headers['X-Correlation-ID'] = generateCorrelationId();
// Skip authentication for public endpoints (e.g., partner showcase, event discovery)
// This prevents 401 errors when expired tokens exist in browser storage
const skipAuth = config.headers['Skip-Auth'] === 'true';
if (skipAuth) {
delete config.headers['Skip-Auth']; // Remove marker header
} else {
// Add authentication token from AWS Amplify (secure storage)
// SEC-001 Fix: Use AWS Amplify's secure token management instead of localStorage
const token = await getIdToken();
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Archive robustness (Story 7.5 follow-up): requests for PUBLIC, optional reads — e.g. the
// per-session Q&A thread rendered on public archive pages — set `skipAuthRedirect` so a 401
// does NOT force-logout the page. The caller's query handles the error and renders empty.
// Without this, an optional read that 401s (an endpoint not yet deployed, or a transient auth
// blip) would bounce an anonymous visitor on a public archive page to /login.
declare module 'axios' {
interface AxiosRequestConfig {
skipAuthRedirect?: boolean;
}
}
/**
* Response interceptor for error handling with correlation ID tracking
*/
apiClient.interceptors.response.use(
(response) => response,
(error) => {
// Extract correlation ID from request or response headers
const correlationId =
error.config?.headers?.['X-Correlation-ID'] ||
error.response?.headers?.['x-correlation-id'] ||
'unknown';
// Handle common HTTP errors
if (error.response) {
const { status } = error.response;
switch (status) {
case 401:
// Public/optional read (e.g. archive Q&A): a 401 must NOT force-logout the page.
Iif (error.config?.skipAuthRedirect) {
console.warn(`[${correlationId}] 401 on skipAuthRedirect request — not redirecting`);
break;
}
// Unauthorized - redirect to login using React Router navigate
console.error(`[${correlationId}] Unauthorized - session expired`);
if (navigateCallback) {
navigateCallback('/login');
} else {
// Fallback to window.location if navigate not configured (shouldn't happen in production)
console.warn('Navigate callback not set - falling back to window.location');
window.location.href = '/login';
}
break;
case 403:
// Story 12.7 / G1 (from Story 12.2 is_active gate): the gateway emits
// 403 { "error": "ACCOUNT_DEACTIVATED", "message": "..." }
// when a user's account is deactivated — for BOTH password and federated
// sessions (the gate is provider-agnostic). Force a clean logout + route to
// the login surface with a deactivated indicator so the user sees a clear
// message rather than a raw 403.
// CRITICAL: this is a 403, NOT a 401 — it must NOT go through the
// token-refresh path (a refresh loop would result).
if (error.response.data?.error === 'ACCOUNT_DEACTIVATED') {
console.error(`[${correlationId}] Account deactivated - forcing logout`);
// Story 12.8 F5: for a FEDERATED session, Amplify signOut() performs a
// full-page redirect to the Cognito hosted-UI /logout — which clobbers the
// in-app navigation below (and its ?reason= param). Persist the reason in
// sessionStorage so it survives the round-trip; LogoutPage/LoginForm consume it.
setLogoutReason('account_deactivated');
// Best-effort sign-out; clears the Amplify session (native or federated).
void authService.signOut().catch(() => {
/* ignore — we redirect regardless */
});
const target = '/login?reason=account_deactivated';
if (navigateCallback) {
navigateCallback(target);
} else E{
window.location.href = target;
}
} else {
// Forbidden - insufficient permissions
console.error(`[${correlationId}] Forbidden: Insufficient permissions`);
}
break;
case 500:
// Server error
console.error(`[${correlationId}] Server error occurred`);
break;
default:
// Log other errors with correlation ID
console.error(`[${correlationId}] API error: ${status}`);
}
} else {
// Network error or timeout
console.error(`[${correlationId}] Network error: ${error.message}`);
}
return Promise.reject(error);
}
);
export default apiClient;
|