refactor(apps/app): extract axios interceptors to registerInterceptors seam
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user