diff --git a/apps/app/src/lib/axios/default.ts b/apps/app/src/lib/axios/default.ts new file mode 100644 index 00000000..4c48b557 --- /dev/null +++ b/apps/app/src/lib/axios/default.ts @@ -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 diff --git a/apps/app/src/lib/axios.ts b/apps/app/src/lib/axios/factory.ts similarity index 53% rename from apps/app/src/lib/axios.ts rename to apps/app/src/lib/axios/factory.ts index 28e2f6cd..d3ec24d5 100644 --- a/apps/app/src/lib/axios.ts +++ b/apps/app/src/lib/axios/factory.ts @@ -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 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 } diff --git a/apps/app/src/lib/axios/index.ts b/apps/app/src/lib/axios/index.ts new file mode 100644 index 00000000..79e5e394 --- /dev/null +++ b/apps/app/src/lib/axios/index.ts @@ -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' diff --git a/apps/app/src/lib/axios/portal-token.ts b/apps/app/src/lib/axios/portal-token.ts new file mode 100644 index 00000000..15c66b9d --- /dev/null +++ b/apps/app/src/lib/axios/portal-token.ts @@ -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 diff --git a/apps/app/src/plugins/3.axios-bindings.ts b/apps/app/src/plugins/3.axios-bindings.ts index b58a2e3a..dc8a200e 100644 --- a/apps/app/src/plugins/3.axios-bindings.ts +++ b/apps/app/src/plugins/3.axios-bindings.ts @@ -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 + }, }) }