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 axios from 'axios'
|
||||||
import type { AxiosInstance, InternalAxiosRequestConfig } 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.
|
* Seam contract between the HTTP client and the rest of the app.
|
||||||
import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
* `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({
|
const apiClient: AxiosInstance = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_URL,
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
@@ -15,94 +24,82 @@ const apiClient: AxiosInstance = axios.create({
|
|||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
})
|
})
|
||||||
|
|
||||||
apiClient.interceptors.request.use(
|
export function registerInterceptors(client: AxiosInstance, deps: AxiosBindingsDeps): void {
|
||||||
(config: InternalAxiosRequestConfig) => {
|
client.interceptors.request.use(
|
||||||
const orgStore = useOrganisationStore()
|
(config: InternalAxiosRequestConfig) => {
|
||||||
|
const orgId = deps.getActiveOrgId()
|
||||||
|
if (orgId)
|
||||||
|
config.headers['X-Organisation-Id'] = orgId
|
||||||
|
|
||||||
if (orgStore.activeOrganisationId)
|
// Read impersonation header directly from sessionStorage — no store dep.
|
||||||
config.headers['X-Organisation-Id'] = orgStore.activeOrganisationId
|
const impersonationData = sessionStorage.getItem('crewli_impersonation')
|
||||||
|
if (impersonationData) {
|
||||||
// Add impersonation header when active
|
try {
|
||||||
// Lazy import to avoid circular dependency with store
|
const parsed = JSON.parse(impersonationData) as { targetUserId?: string }
|
||||||
const impersonationData = sessionStorage.getItem('crewli_impersonation')
|
if (parsed.targetUserId)
|
||||||
if (impersonationData) {
|
config.headers['X-Impersonate-User'] = parsed.targetUserId
|
||||||
try {
|
}
|
||||||
const parsed = JSON.parse(impersonationData) as { targetUserId?: string }
|
catch {
|
||||||
if (parsed.targetUserId)
|
// Invalid data — ignore
|
||||||
config.headers['X-Impersonate-User'] = parsed.targetUserId
|
}
|
||||||
}
|
}
|
||||||
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)
|
if (status === 401) {
|
||||||
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
|
deps.onAuthFail()
|
||||||
|
}
|
||||||
return config
|
else if (status === 403) {
|
||||||
},
|
deps.notify('You don\'t have permission for this action.', 'error')
|
||||||
async error => { throw error },
|
}
|
||||||
)
|
else if (status === 404) {
|
||||||
|
deps.notify('The requested item was not found.', 'warning')
|
||||||
apiClient.interceptors.response.use(
|
}
|
||||||
response => {
|
else if (status === 422) {
|
||||||
if (import.meta.env.DEV)
|
const message = error.response?.data?.message
|
||||||
console.log(`✅ ${response.status} ${response.config.url}`, response.data)
|
if (message && typeof message === 'string')
|
||||||
|
deps.notify(message, 'error')
|
||||||
return response
|
}
|
||||||
},
|
else if (status === 503) {
|
||||||
async error => {
|
deps.notify('Service temporarily unavailable. Please try again later.', 'error')
|
||||||
if (import.meta.env.DEV)
|
}
|
||||||
console.error(`❌ ${error.response?.status} ${error.config?.url}`, error.response?.data)
|
else if (status && status >= 500) {
|
||||||
|
deps.notify('An unexpected error occurred. Please try again later.', 'error')
|
||||||
const status = error.response?.status
|
}
|
||||||
const notificationStore = useNotificationStore()
|
else if (!error.response) {
|
||||||
|
deps.notify('Unable to connect to the server. Check your internet connection.', 'error')
|
||||||
// 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'
|
|
||||||
|
|
||||||
throw 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 }
|
export { apiClient }
|
||||||
|
|||||||
Reference in New Issue
Block a user