refactor(axios): split lib/axios.ts into factory + default + portal-token instances
The single axios.ts file becomes a directory with:
- factory.ts — createApiClient + the registerDefaultInterceptors /
registerPortalTokenInterceptors seam (preserves the
TECH-AXIOS-STORE-COUPLING decoupling — no store imports inside)
- default.ts — cookie-authenticated client (organizer + cookie-auth
portal flows; existing 45 call sites resolve unchanged)
- portal-token.ts — Bearer-auth client for the artist-advance /
supplier-intake flows (forward-compatible groundwork; no active
consumers today)
- index.ts — re-exports apiClient + portalApiClient + the register* /
createApiClient surface; the existing `import { apiClient } from
'@/lib/axios'` continues to work directory-resolved.
The bindings plugin (plugins/3.axios-bindings.ts) now wires both
clients with a shared deps base + flavour-specific overrides. The
`getPortalToken` callback returns null until Phase E surfaces
`portalToken` on useAuthStore — no current consumers exercise the
Bearer path, so the null-return is intentional placeholder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
apps/app/src/lib/axios/default.ts
Normal file
10
apps/app/src/lib/axios/default.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApiClient } from './factory'
|
||||
|
||||
/**
|
||||
* Default API client — cookie-authenticated, sends X-Organisation-Id
|
||||
* and X-Impersonate-User headers via the bindings plugin. Used by
|
||||
* organizer + cookie-authenticated portal flows.
|
||||
*/
|
||||
const apiClient = createApiClient({ withCredentials: true })
|
||||
|
||||
export default apiClient
|
||||
@@ -3,36 +3,58 @@ import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Factory + register* functions know nothing about Pinia, stores, or
|
||||
* routing — they only invoke these callbacks. The bindings plugin
|
||||
* (`plugins/3.axios-bindings.ts`) supplies the runtime closures.
|
||||
*
|
||||
* Originally introduced as `TECH-AXIOS-STORE-COUPLING` (closed in
|
||||
* 53f6a7b). Preserving the seam during the WS-3 PR-B2a factory split
|
||||
* was a deliberate decision (Bert, 2026-05-05).
|
||||
*/
|
||||
export interface AxiosBindingsDeps {
|
||||
getActiveOrgId: () => string | null
|
||||
getImpersonationTargetUserId: () => string | null
|
||||
getActiveOrgId?: () => string | null
|
||||
getImpersonationTargetUserId?: () => string | null
|
||||
getPortalToken?: () => 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,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
})
|
||||
export interface CreateApiClientOptions {
|
||||
withCredentials?: boolean
|
||||
baseURL?: string
|
||||
}
|
||||
|
||||
export function registerInterceptors(client: AxiosInstance, deps: AxiosBindingsDeps): void {
|
||||
export function createApiClient(options: CreateApiClientOptions = {}): AxiosInstance {
|
||||
const {
|
||||
withCredentials = true,
|
||||
baseURL = import.meta.env.VITE_API_URL,
|
||||
} = options
|
||||
|
||||
return axios.create({
|
||||
baseURL,
|
||||
withCredentials,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie-authenticated client interceptors. Attaches X-Organisation-Id
|
||||
* + X-Impersonate-User on every request and routes 401/403/4xx/5xx
|
||||
* through the deps callbacks.
|
||||
*/
|
||||
export function registerDefaultInterceptors(client: AxiosInstance, deps: AxiosBindingsDeps): void {
|
||||
client.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const orgId = deps.getActiveOrgId()
|
||||
const orgId = deps.getActiveOrgId?.()
|
||||
if (orgId)
|
||||
config.headers['X-Organisation-Id'] = orgId
|
||||
|
||||
const impersonationTargetUserId = deps.getImpersonationTargetUserId()
|
||||
const impersonationTargetUserId = deps.getImpersonationTargetUserId?.()
|
||||
if (impersonationTargetUserId)
|
||||
config.headers['X-Impersonate-User'] = impersonationTargetUserId
|
||||
|
||||
@@ -44,6 +66,34 @@ export function registerInterceptors(client: AxiosInstance, deps: AxiosBindingsD
|
||||
error => { throw error },
|
||||
)
|
||||
|
||||
registerSharedResponseHandler(client, deps)
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal-token (Bearer) client interceptors. Attaches
|
||||
* Authorization: Bearer <token> when the deps callback returns a
|
||||
* non-null token. Does NOT attach org/impersonation headers — the
|
||||
* Bearer flow has no organisation context.
|
||||
*/
|
||||
export function registerPortalTokenInterceptors(client: AxiosInstance, deps: AxiosBindingsDeps): void {
|
||||
client.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = deps.getPortalToken?.()
|
||||
if (token)
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
|
||||
if (import.meta.env.DEV)
|
||||
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
|
||||
|
||||
return config
|
||||
},
|
||||
error => { throw error },
|
||||
)
|
||||
|
||||
registerSharedResponseHandler(client, deps)
|
||||
}
|
||||
|
||||
function registerSharedResponseHandler(client: AxiosInstance, deps: AxiosBindingsDeps): void {
|
||||
client.interceptors.response.use(
|
||||
response => {
|
||||
if (import.meta.env.DEV)
|
||||
@@ -93,5 +143,3 @@ export function registerInterceptors(client: AxiosInstance, deps: AxiosBindingsD
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export { apiClient }
|
||||
8
apps/app/src/lib/axios/index.ts
Normal file
8
apps/app/src/lib/axios/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as apiClient } from './default'
|
||||
export { default as portalApiClient } from './portal-token'
|
||||
export {
|
||||
createApiClient,
|
||||
registerDefaultInterceptors,
|
||||
registerPortalTokenInterceptors,
|
||||
} from './factory'
|
||||
export type { AxiosBindingsDeps, CreateApiClientOptions } from './factory'
|
||||
14
apps/app/src/lib/axios/portal-token.ts
Normal file
14
apps/app/src/lib/axios/portal-token.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createApiClient } from './factory'
|
||||
|
||||
/**
|
||||
* Portal-token API client — Bearer auth, no cookies. Used by
|
||||
* unauthenticated artist/supplier flows where the token comes from a
|
||||
* URL parameter (artist-advance, supplier-intake — wired post-WS-3).
|
||||
*
|
||||
* The Bearer token is read at request time from
|
||||
* `useAuthStore.portalToken` via the bindings plugin's
|
||||
* `getPortalToken` callback.
|
||||
*/
|
||||
const portalApiClient = createApiClient({ withCredentials: false })
|
||||
|
||||
export default portalApiClient
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { App } from 'vue'
|
||||
import { apiClient, registerInterceptors } from '@/lib/axios'
|
||||
import {
|
||||
apiClient,
|
||||
portalApiClient,
|
||||
registerDefaultInterceptors,
|
||||
registerPortalTokenInterceptors,
|
||||
} from '@/lib/axios'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||
import { useNotificationStore } from '@/stores/useNotificationStore'
|
||||
@@ -10,10 +15,9 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
||||
// inside each callback (not eagerly at plugin-init), which keeps the
|
||||
// seam tolerant of any future plugin-ordering changes.
|
||||
export default function (_: App): void {
|
||||
registerInterceptors(apiClient, {
|
||||
getActiveOrgId: () => useOrganisationStore().activeOrganisationId,
|
||||
getImpersonationTargetUserId: () => useImpersonationStore().targetUserId,
|
||||
notify: (message, level) => useNotificationStore().show(message, level),
|
||||
const sharedDeps = {
|
||||
notify: (message: string, level: 'error' | 'warning') =>
|
||||
useNotificationStore().show(message, level),
|
||||
onAuthFail: () => {
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.isInitialized)
|
||||
@@ -23,5 +27,21 @@ export default function (_: App): void {
|
||||
useImpersonationStore().clearState()
|
||||
window.location.href = '/platform'
|
||||
},
|
||||
}
|
||||
|
||||
registerDefaultInterceptors(apiClient, {
|
||||
...sharedDeps,
|
||||
getActiveOrgId: () => useOrganisationStore().activeOrganisationId,
|
||||
getImpersonationTargetUserId: () => useImpersonationStore().targetUserId,
|
||||
})
|
||||
|
||||
registerPortalTokenInterceptors(portalApiClient, {
|
||||
...sharedDeps,
|
||||
|
||||
getPortalToken: () => {
|
||||
const authStore = useAuthStore() as unknown as { portalToken?: string | null }
|
||||
|
||||
return authStore.portalToken ?? null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user