// api.ts import axios from 'axios'; import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils'; import { clearUserDataCache, getUserDataCache } from './utils/userCache'; // Utility function to resolve hostname to IP address const resolveHostnameToIP = async (hostname: string): Promise => { try { // For localhost, return as is if (hostname === 'localhost' || hostname === '127.0.0.1') { return hostname; } // For production domains, we can't directly resolve IP due to CORS // But we can show the hostname which is more useful anyway return hostname; } catch (error) { console.warn('Could not resolve hostname to IP:', error); return hostname; } }; /** * Extract mandate/instance context from current URL. * URL pattern: /mandates/:mandateId/:featureCode/:instanceId/... * * Only feature pages under /mandates/... provide context via URL. * Admin pages (e.g., /admin/users) do NOT send mandate context -- * admin endpoints aggregate across all user mandates server-side. */ const getContextFromUrl = (): { mandateId?: string; instanceId?: string } => { const pathname = window.location.pathname; const match = pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/); if (match) { return { mandateId: match[1], instanceId: match[3] }; } return {}; }; import { getApiBaseUrl } from '../config/config'; const api = axios.create({ baseURL: getApiBaseUrl(), withCredentials: true, // FastAPI expects repeat-style array query params (``?ids=1&ids=2``). // Axios v1.x default would render ``?ids[]=1&ids[]=2``, which FastAPI // silently drops -- e.g. ``trackerIds`` filters on the Redmine stats // endpoint never reach the route. Setting ``indexes: null`` switches // the URLSearchParams visitor to repeat format. Applies globally so // every endpoint with array query params gets it for free. paramsSerializer: { indexes: null }, }); // Add a request interceptor to add the auth token, context headers, and log backend IP api.interceptors.request.use( async (config) => { // Log backend information const backendUrl = config.baseURL || getApiBaseUrl(); console.log(`🌐 Communicating with backend: ${backendUrl}`); // Try to resolve and log the IP address if (backendUrl) { try { const url = new URL(backendUrl); const hostname = url.hostname; const resolvedIP = await resolveHostnameToIP(hostname); console.log(`📍 Backend hostname: ${hostname}`); console.log(`🔗 Full backend URL: ${backendUrl}`); console.log(`🌍 Resolved address: ${resolvedIP}`); // Log environment info console.log(`🏗️ Environment: ${import.meta.env.MODE}`); console.log(`⚙️ API Base URL: ${getApiBaseUrl()}`); } catch (error) { console.warn('Could not parse backend URL:', error); } } // Check for auth token in localStorage and add to headers const authToken = localStorage.getItem('authToken'); if (authToken && config.headers) { config.headers.Authorization = `Bearer ${authToken}`; console.log('🔑 Using Bearer token for authentication'); } else { // Fallback: httpOnly cookies console.log('🍪 Using httpOnly cookies for authentication (automatic)'); } // Send app language to backend so i18n labels match the UI const userData = getUserDataCache(); const appLanguage = userData?.language || navigator.language.split('-')[0] || 'de'; if (config.headers) { config.headers['Accept-Language'] = appLanguage; } // Send browser IANA timezone (e.g. "Europe/Zurich") so the gateway can // resolve "now" for AI agents and user-visible time strings without // hardcoding a server-side default. Mirrors the Accept-Language pattern. if (config.headers) { try { const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; if (browserTimezone) { config.headers['X-User-Timezone'] = browserTimezone; } } catch { // Older browsers without Intl.DateTimeFormat: backend falls back to UTC } } // Add multi-tenant context headers from URL (if not already set) // This ensures Feature-Instance roles are loaded for permission checks const context = getContextFromUrl(); if (config.headers) { if (context.mandateId && !config.headers['X-Mandate-Id']) { config.headers['X-Mandate-Id'] = context.mandateId; } if (context.instanceId && !config.headers['X-Instance-Id']) { config.headers['X-Instance-Id'] = context.instanceId; } } // Add CSRF token to all requests (including GET requests for certain endpoints) // Some endpoints like /api/realestate/* require CSRF tokens even for GET requests const method = config.method?.toLowerCase(); const url = config.url || ''; const requiresCSRF = ['post', 'put', 'patch', 'delete'].includes(method || '') || url.includes('/api/realestate/'); if (requiresCSRF) { // Ensure CSRF token exists, generate one if missing if (!getCSRFToken()) { generateAndStoreCSRFToken(); } addCSRFTokenToHeaders(config.headers as Record); } return config; }, (error) => { return Promise.reject(error); } ); // Add a response interceptor to handle token expiration api.interceptors.response.use( (response) => response, async (error) => { if (error.response?.status === 401) { // Don't redirect to login if the request was to a login endpoint const isLoginEndpoint = error.config?.url?.includes('/login') || error.config?.url?.includes('/api/local/login') || error.config?.url?.includes('/api/msft/auth/login') || error.config?.url?.includes('/api/google/auth/login'); // Don't redirect if we're on a public auth page (prevents redirect loops and allows public pages to work) const pathname = window.location.pathname; const isOnPublicAuthPage = pathname === '/login' || pathname.startsWith('/login') || pathname === '/register' || pathname.startsWith('/register') || pathname === '/reset' || pathname.startsWith('/reset') || pathname === '/password-reset-request' || pathname.startsWith('/password-reset-request') || pathname.startsWith('/invite'); if (!isLoginEndpoint && !isOnPublicAuthPage) { // Clear local auth data (httpOnly cookies are cleared by backend) sessionStorage.removeItem('auth_authority'); clearUserDataCache(); // Redirect to login window.location.href = '/login'; } } // Handle rate limiting (429) - don't throw, just log and return error if (error.response?.status === 429) { console.warn('Rate limit exceeded (429). Please wait before making more requests.'); // Don't cause cascading errors by throwing here } return Promise.reject(error); } ); export default api;