From 53f6a7be73de793e76ed7365294c895daf661584 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Mon, 4 May 2026 22:22:33 +0200 Subject: [PATCH] refactor(apps/app): extract axios interceptors to registerInterceptors seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the lib → stores boundary violations that WS-3 sessie 1c flagged. lib/axios.ts is now pure HTTP infrastructure: it exports the configured `apiClient` plus a `registerInterceptors(client, deps)` function that takes a typed `AxiosBindingsDeps` callback bag (`getActiveOrgId`, `notify`, `onAuthFail`, `onImpersonationRevoked`). All four `eslint-disable-next-line boundaries/element-types` comments referencing TECH-AXIOS-STORE-COUPLING are removed in the same change because the imports they suppressed are gone — they would otherwise be orphan disables. Behavior is preserved 1:1: same status-code branching, same toast messages, same DEV-only console logs, same sessionStorage-driven X-Impersonate-User header (which never depended on a store and stays in lib/axios.ts as before). The two redirects that used to live in axios.ts (`/platform` on impersonation revocation, `/login` on auth fail) move into the bindings-plugin closures so the HTTP module stops knowing about routing. The `apiClient` singleton is now exported without interceptors attached — the bindings plugin (`plugins/3.axios-bindings.ts`, follow-up commit) wires them up during plugin-init, before `app.mount`. Refs TECH-AXIOS-STORE-COUPLING. Co-Authored-By: Claude --- apps/app/src/lib/axios.ts | 171 +++++++++++++++++++------------------- 1 file changed, 84 insertions(+), 87 deletions(-) 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 }