diff --git a/apps/app/src/lib/axios.ts b/apps/app/src/lib/axios.ts index a40404db..d9e616be 100644 --- a/apps/app/src/lib/axios.ts +++ b/apps/app/src/lib/axios.ts @@ -1,9 +1,18 @@ import axios from 'axios' import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios' -// eslint-disable-next-line boundaries/element-types -- TECH-AXIOS-STORE-COUPLING: deliberate HTTP↔state seam, refactor scheduled per backlog. -import { useNotificationStore } from '@/stores/useNotificationStore' -// eslint-disable-next-line boundaries/element-types -- TECH-AXIOS-STORE-COUPLING: deliberate HTTP↔state seam, refactor scheduled per backlog. -import { useOrganisationStore } from '@/stores/useOrganisationStore' + +/** + * Seam contract between the HTTP client and the rest of the app. + * `lib/axios.ts` knows nothing about Pinia, stores, or routing — it + * only invokes these callbacks. The bindings plugin + * (`plugins/3.axios-bindings.ts`) supplies the runtime closures. + */ +export interface AxiosBindingsDeps { + getActiveOrgId: () => string | null + notify: (message: string, level: 'error' | 'warning') => void + onAuthFail: () => void + onImpersonationRevoked: () => void +} const apiClient: AxiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_URL, @@ -15,94 +24,82 @@ const apiClient: AxiosInstance = axios.create({ timeout: 30000, }) -apiClient.interceptors.request.use( - (config: InternalAxiosRequestConfig) => { - const orgStore = useOrganisationStore() +export function registerInterceptors(client: AxiosInstance, deps: AxiosBindingsDeps): void { + client.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const orgId = deps.getActiveOrgId() + if (orgId) + config.headers['X-Organisation-Id'] = orgId - if (orgStore.activeOrganisationId) - config.headers['X-Organisation-Id'] = orgStore.activeOrganisationId - - // Add impersonation header when active - // Lazy import to avoid circular dependency with store - const impersonationData = sessionStorage.getItem('crewli_impersonation') - if (impersonationData) { - try { - const parsed = JSON.parse(impersonationData) as { targetUserId?: string } - if (parsed.targetUserId) - config.headers['X-Impersonate-User'] = parsed.targetUserId + // Read impersonation header directly from sessionStorage — no store dep. + const impersonationData = sessionStorage.getItem('crewli_impersonation') + if (impersonationData) { + try { + const parsed = JSON.parse(impersonationData) as { targetUserId?: string } + if (parsed.targetUserId) + config.headers['X-Impersonate-User'] = parsed.targetUserId + } + catch { + // Invalid data — ignore + } } - catch { - // Invalid data — ignore + + if (import.meta.env.DEV) + console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data) + + return config + }, + async error => { throw error }, + ) + + client.interceptors.response.use( + response => { + if (import.meta.env.DEV) + console.log(`✅ ${response.status} ${response.config.url}`, response.data) + + return response + }, + async error => { + if (import.meta.env.DEV) + console.error(`❌ ${error.response?.status} ${error.config?.url}`, error.response?.data) + + const status = error.response?.status + + // Backend revoked the impersonation session mid-flight; the callback + // owns the local cleanup and the post-revoke redirect. + if (status === 403 && error.response?.data?.impersonation_ended) { + deps.onImpersonationRevoked() + + throw error } - } - if (import.meta.env.DEV) - console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data) - - return config - }, - async error => { throw error }, -) - -apiClient.interceptors.response.use( - response => { - if (import.meta.env.DEV) - console.log(`✅ ${response.status} ${response.config.url}`, response.data) - - return response - }, - async error => { - if (import.meta.env.DEV) - console.error(`❌ ${error.response?.status} ${error.config?.url}`, error.response?.data) - - const status = error.response?.status - const notificationStore = useNotificationStore() - - // Handle impersonation session expiry - if (status === 403 && error.response?.data?.impersonation_ended) { - // eslint-disable-next-line boundaries/element-types -- TECH-AXIOS-STORE-COUPLING: deliberate HTTP↔state seam, refactor scheduled per backlog. - const { useImpersonationStore } = await import('@/stores/useImpersonationStore') - const impersonationStore = useImpersonationStore() - - impersonationStore.clearState() - window.location.href = '/platform' + if (status === 401) { + deps.onAuthFail() + } + else if (status === 403) { + deps.notify('You don\'t have permission for this action.', 'error') + } + else if (status === 404) { + deps.notify('The requested item was not found.', 'warning') + } + else if (status === 422) { + const message = error.response?.data?.message + if (message && typeof message === 'string') + deps.notify(message, 'error') + } + else if (status === 503) { + deps.notify('Service temporarily unavailable. Please try again later.', 'error') + } + else if (status && status >= 500) { + deps.notify('An unexpected error occurred. Please try again later.', 'error') + } + else if (!error.response) { + deps.notify('Unable to connect to the server. Check your internet connection.', 'error') + } throw error - } - - if (status === 401) { - // Lazy import to avoid circular dependency - // eslint-disable-next-line boundaries/element-types -- TECH-AXIOS-STORE-COUPLING: deliberate HTTP↔state seam, refactor scheduled per backlog. - const { useAuthStore } = await import('@/stores/useAuthStore') - const authStore = useAuthStore() - if (authStore.isInitialized) - authStore.handleUnauthorized() - } - else if (status === 403) { - notificationStore.show('You don\'t have permission for this action.', 'error') - } - else if (status === 404) { - notificationStore.show('The requested item was not found.', 'warning') - } - else if (status === 422) { - // Show validation message to user; still reject so component onError handlers can react - const message = error.response?.data?.message - if (message && typeof message === 'string') - notificationStore.show(message, 'error') - } - else if (status === 503) { - notificationStore.show('Service temporarily unavailable. Please try again later.', 'error') - } - else if (status && status >= 500) { - notificationStore.show('An unexpected error occurred. Please try again later.', 'error') - } - else if (!error.response) { - // Network error — no response received - notificationStore.show('Unable to connect to the server. Check your internet connection.', 'error') - } - - throw error - }, -) + }, + ) +} export { apiClient }