// Copyright (c) 2026 PowerOn AG // All rights reserved. // api.ts import axios from 'axios'; import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils'; import { clearUserDataCache, getUserDataCache } from './utils/userCache'; /** * 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 _baseUrl = getApiBaseUrl(); if (import.meta.env.DEV) { console.log(`[api] Backend: ${_baseUrl} | env: ${import.meta.env.MODE}`); } const api = axios.create({ baseURL: _baseUrl, withCredentials: true, paramsSerializer: { indexes: null }, }); // Add a request interceptor to add the auth token, context headers api.interceptors.request.use( async (config) => { // Add auth token if available (otherwise httpOnly cookies are used automatically) const authToken = localStorage.getItem('authToken'); if (authToken && config.headers) { config.headers.Authorization = `Bearer ${authToken}`; } // 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); } ); // Silent refresh: attempt token renewal before forcing re-login let _isRefreshing = false; let _refreshSubscribers: Array<(success: boolean) => void> = []; function _onRefreshDone(success: boolean) { _refreshSubscribers.forEach(cb => cb(success)); _refreshSubscribers = []; } api.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; if (error.response?.status === 401) { const isAuthEndpoint = originalRequest?.url?.includes('/login') || originalRequest?.url?.includes('/api/local/login') || originalRequest?.url?.includes('/api/local/refresh') || originalRequest?.url?.includes('/api/msft/auth/login') || originalRequest?.url?.includes('/api/google/auth/login'); 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 (isAuthEndpoint || isOnPublicAuthPage) { return Promise.reject(error); } // Attempt silent refresh (only once per request) if (!originalRequest._retryAfterRefresh) { originalRequest._retryAfterRefresh = true; if (!_isRefreshing) { _isRefreshing = true; try { await api.post('/api/local/refresh'); _isRefreshing = false; _onRefreshDone(true); return api(originalRequest); } catch { _isRefreshing = false; _onRefreshDone(false); sessionStorage.removeItem('auth_authority'); clearUserDataCache(); window.location.href = '/login'; return Promise.reject(error); } } else { // Another request is already refreshing; queue this one return new Promise((resolve, reject) => { _refreshSubscribers.push((success: boolean) => { if (success) { resolve(api(originalRequest)); } else { reject(error); } }); }); } } // Refresh already failed for this request sessionStorage.removeItem('auth_authority'); clearUserDataCache(); window.location.href = '/login'; } // Handle rate limiting (429) if (error.response?.status === 429) { console.warn('Rate limit exceeded (429). Please wait before making more requests.'); } return Promise.reject(error); } ); export default api;