diff --git a/apps/portal/src/core/api/client.ts b/apps/portal/src/core/api/client.ts index 946a24da..b1d846c6 100644 --- a/apps/portal/src/core/api/client.ts +++ b/apps/portal/src/core/api/client.ts @@ -1,13 +1,27 @@ /** - * Core API Client - * Minimal API client setup using the generated OpenAPI client + * Core API client configuration + * Wraps the shared generated client to inject portal-specific behavior like auth headers. */ -import { createClient } from "@customer-portal/api-client"; +import { + createClient as createOpenApiClient, + type ApiClient as GeneratedApiClient, + type AuthHeaderResolver, +} from "@customer-portal/api-client"; import { env } from "../config/env"; -// Create the type-safe API client -export const apiClient = createClient(env.NEXT_PUBLIC_API_BASE); +const baseUrl = env.NEXT_PUBLIC_API_BASE; -// Export the client type for use in hooks -export type { ApiClient } from "@customer-portal/api-client"; +let authHeaderResolver: AuthHeaderResolver | undefined; + +const resolveAuthHeader: AuthHeaderResolver = () => authHeaderResolver?.(); + +export const apiClient: GeneratedApiClient = createOpenApiClient(baseUrl, { + getAuthHeader: resolveAuthHeader, +}); + +export const configureApiClientAuth = (resolver?: AuthHeaderResolver) => { + authHeaderResolver = resolver; +}; + +export type ApiClient = GeneratedApiClient; diff --git a/apps/portal/src/core/api/index.ts b/apps/portal/src/core/api/index.ts index e3689269..75bf298e 100644 --- a/apps/portal/src/core/api/index.ts +++ b/apps/portal/src/core/api/index.ts @@ -1,3 +1,4 @@ -export { apiClient } from "./client"; +export { apiClient, configureApiClientAuth } from "./client"; export { queryKeys } from "./query-keys"; export type { ApiClient } from "./client"; +export type { AuthHeaderResolver } from "@customer-portal/api-client"; diff --git a/apps/portal/src/features/account/hooks/useProfileData.ts b/apps/portal/src/features/account/hooks/useProfileData.ts index dcf9e216..4623182e 100644 --- a/apps/portal/src/features/account/hooks/useProfileData.ts +++ b/apps/portal/src/features/account/hooks/useProfileData.ts @@ -85,22 +85,11 @@ export function useProfileData() { const saveProfile = async (next: ProfileFormData) => { setIsSavingProfile(true); try { - const { tokens } = useAuthStore.getState(); - if (!tokens?.accessToken) throw new Error("Authentication required"); - const response = await fetch("/api/me", { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${tokens.accessToken}`, - }, - body: JSON.stringify({ - firstName: next.firstName, - lastName: next.lastName, - phone: next.phone, - }), + const updatedUser = await accountService.updateProfile({ + firstName: next.firstName, + lastName: next.lastName, + phone: next.phone, }); - if (!response.ok) throw new Error("Failed to update profile"); - const updatedUser = (await response.json()) as Partial; useAuthStore.setState(state => ({ ...state, user: state.user ? { ...state.user, ...updatedUser } : state.user, diff --git a/apps/portal/src/features/account/services/account.service.ts b/apps/portal/src/features/account/services/account.service.ts new file mode 100644 index 00000000..c5ddbce4 --- /dev/null +++ b/apps/portal/src/features/account/services/account.service.ts @@ -0,0 +1,37 @@ +import { apiClient } from "@/core/api"; +import type { Address, AuthUser } from "@customer-portal/domain"; + +const ensureData = (data: T | undefined): T => { + if (typeof data === "undefined") { + throw new Error("No data returned from server"); + } + return data; +}; + +type ProfileUpdateInput = { + firstName?: string; + lastName?: string; + phone?: string; +}; + +export const accountService = { + async getProfile() { + const response = await apiClient.GET('/me'); + return ensureData(response.data); + }, + + async updateProfile(update: ProfileUpdateInput) { + const response = await apiClient.PATCH('/me', { body: update }); + return ensureData(response.data); + }, + + async getAddress() { + const response = await apiClient.GET('/me/address'); + return ensureData
(response.data); + }, + + async updateAddress(address: Address) { + const response = await apiClient.PATCH('/me/address', { body: address }); + return ensureData
(response.data); + }, +}; diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index b5283aed..af1e644c 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -5,7 +5,7 @@ import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; -import { apiClient } from "@/core/api"; +import { apiClient, configureApiClientAuth } from "@/core/api"; import type { AuthTokens, SignupRequest, @@ -17,6 +17,62 @@ import type { AuthError, } from "@customer-portal/domain"; +const DEFAULT_TOKEN_TYPE = "Bearer"; + +type RawAuthTokens = + | AuthTokens + | (Omit & { expiresAt: string | number | Date }); + +const toIsoString = (value: string | number | Date) => { + if (value instanceof Date) { + return value.toISOString(); + } + + if (typeof value === "number") { + return new Date(value).toISOString(); + } + + return value; +}; + +const normalizeAuthTokens = (tokens: RawAuthTokens): AuthTokens => ({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + tokenType: tokens.tokenType ?? DEFAULT_TOKEN_TYPE, + expiresAt: toIsoString(tokens.expiresAt), +}); + +const getExpiryTime = (tokens?: AuthTokens | null) => { + if (!tokens?.expiresAt) { + return null; + } + + const expiry = Date.parse(tokens.expiresAt); + return Number.isNaN(expiry) ? null : expiry; +}; + +const hasValidAccessToken = (tokens?: AuthTokens | null) => { + if (!tokens?.accessToken) { + return false; + } + + const expiry = getExpiryTime(tokens); + if (expiry === null) { + return false; + } + + return Date.now() < expiry; +}; + +const buildAuthorizationHeader = (tokens?: AuthTokens | null) => { + if (!tokens?.accessToken) { + return undefined; + } + + const tokenType = tokens.tokenType ?? DEFAULT_TOKEN_TYPE; + return `${tokenType} ${tokens.accessToken}`; +}; + interface AuthState { isAuthenticated: boolean; user: AuthUser | null; @@ -54,7 +110,7 @@ interface AuthStoreState extends AuthState { setLoading: (loading: boolean) => void; setError: (error: string | null) => void; setUser: (user: AuthUser | null) => void; - setTokens: (tokens: AuthTokens | null) => void; + setTokens: (tokens: RawAuthTokens | null) => void; } export const useAuthStore = create()( @@ -76,9 +132,10 @@ export const useAuthStore = create()( try { const response = await apiClient.POST('/auth/login', { body: credentials }); if (response.data) { + const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens); set({ user: response.data.user, - tokens: response.data.tokens, + tokens, isAuthenticated: true, loading: false, error: null, @@ -104,9 +161,10 @@ export const useAuthStore = create()( try { const response = await apiClient.POST('/auth/signup', { body: data }); if (response.data) { + const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens); set({ user: response.data.user, - tokens: response.data.tokens, + tokens, isAuthenticated: true, loading: false, error: null, @@ -164,9 +222,10 @@ export const useAuthStore = create()( try { const response = await apiClient.POST('/auth/reset-password', { body: data }); const { user, tokens } = response.data!; + const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens); set({ user, - tokens, + tokens: normalizedTokens, isAuthenticated: true, loading: false, error: null, @@ -194,9 +253,10 @@ export const useAuthStore = create()( try { const response = await apiClient.POST('/auth/change-password', { body: data }); const { user, tokens: newTokens } = response.data!; + const normalizedTokens = normalizeAuthTokens(newTokens as RawAuthTokens); set({ user, - tokens: newTokens, + tokens: normalizedTokens, loading: false, error: null, }); @@ -240,9 +300,10 @@ export const useAuthStore = create()( try { const response = await apiClient.POST('/auth/set-password', { body: { email, password } }); const { user, tokens } = response.data!; + const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens); set({ user, - tokens, + tokens: normalizedTokens, isAuthenticated: true, loading: false, error: null, @@ -264,22 +325,26 @@ export const useAuthStore = create()( checkAuth: async () => { const { tokens } = get(); - if (!tokens?.accessToken) { - set({ isAuthenticated: false, loading: false, user: null, tokens: null, hasCheckedAuth: true }); - return; - } - - // Check if token is expired - if (Date.now() >= tokens.expiresAt) { - set({ isAuthenticated: false, loading: false, user: null, tokens: null, hasCheckedAuth: true }); + if (!hasValidAccessToken(tokens)) { + set({ + isAuthenticated: false, + loading: false, + user: null, + tokens: null, + hasCheckedAuth: true, + }); return; } set({ loading: true }); try { const response = await apiClient.GET('/me'); - const user = response.data!; - set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true }); + const user = response.data ?? null; + if (user) { + set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true }); + } else { + set({ loading: false, hasCheckedAuth: true }); + } } catch (error) { // Token is invalid, clear auth state console.info("Token validation failed, clearing auth state"); @@ -301,8 +366,14 @@ export const useAuthStore = create()( return; } + const expiry = getExpiryTime(tokens); + if (expiry === null) { + await checkAuth(); + return; + } + // Check if token needs refresh (expires within 5 minutes) - if (Date.now() >= tokens.expiresAt - 5 * 60 * 1000) { // 5 minutes before expiry + if (Date.now() >= expiry - 5 * 60 * 1000) { // For now, just re-validate the token // In a real implementation, you would call a refresh token endpoint await checkAuth(); @@ -318,7 +389,8 @@ export const useAuthStore = create()( setUser: (user: AuthUser | null) => set({ user }), - setTokens: (tokens: AuthTokens | null) => set({ tokens }), + setTokens: (tokens: RawAuthTokens | null) => + set({ tokens: tokens ? normalizeAuthTokens(tokens) : null }), }), { name: "auth-store", @@ -352,18 +424,45 @@ export const useAuthStore = create()( ) ); +const resolveAuthorizationHeader = () => + buildAuthorizationHeader(useAuthStore.getState().tokens); + +configureApiClientAuth(resolveAuthorizationHeader); + +export const getAuthorizationHeader = resolveAuthorizationHeader; + +export const selectAuthTokens = (state: AuthStoreState) => state.tokens; +export const selectIsAuthenticated = (state: AuthStoreState) => state.isAuthenticated; +export const selectHasValidAccessToken = (state: AuthStoreState) => + hasValidAccessToken(state.tokens); + +export const useAuthSession = () => + useAuthStore(state => ({ + isAuthenticated: state.isAuthenticated, + hasValidToken: hasValidAccessToken(state.tokens), + tokens: state.tokens, + })); + // Session timeout detection let sessionTimeoutId: NodeJS.Timeout | null = null; export const startSessionTimeout = () => { const checkSession = () => { const state = useAuthStore.getState(); - if (state.tokens?.accessToken) { - if (Date.now() >= state.tokens.expiresAt) { - void state.logout(); - } else { - void state.refreshSession(); - } + if (!state.tokens?.accessToken) { + return; + } + + const expiry = getExpiryTime(state.tokens); + if (expiry === null) { + void state.logout(); + return; + } + + if (Date.now() >= expiry) { + void state.logout(); + } else { + void state.refreshSession(); } }; diff --git a/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx index c0d8309e..06c4b157 100644 --- a/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx +++ b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx @@ -42,8 +42,11 @@ export function ChangePlanModal({ } setLoading(true); try { - await apiClient.post(`/subscriptions/${subscriptionId}/sim/change-plan`, { - newPlanCode: newPlanCode, + await apiClient.POST("/subscriptions/{id}/sim/change-plan", { + params: { path: { id: subscriptionId } }, + body: { + newPlanCode, + }, }); onSuccess(); } catch (e: unknown) { diff --git a/apps/portal/src/features/sim-management/components/SimActions.tsx b/apps/portal/src/features/sim-management/components/SimActions.tsx index 105d3791..5b436065 100644 --- a/apps/portal/src/features/sim-management/components/SimActions.tsx +++ b/apps/portal/src/features/sim-management/components/SimActions.tsx @@ -58,7 +58,9 @@ export function SimActions({ setError(null); try { - await apiClient.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`); + await apiClient.POST("/subscriptions/{id}/sim/reissue-esim", { + params: { path: { id: subscriptionId } }, + }); setSuccess("eSIM profile reissued successfully"); setShowReissueConfirm(false); @@ -75,7 +77,9 @@ export function SimActions({ setError(null); try { - await apiClient.post(`/subscriptions/${subscriptionId}/sim/cancel`, {}); + await apiClient.POST("/subscriptions/{id}/sim/cancel", { + params: { path: { id: subscriptionId } }, + }); setSuccess("SIM service cancelled successfully"); setShowCancelConfirm(false); diff --git a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx index 2db603c5..6ad58384 100644 --- a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx +++ b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx @@ -75,10 +75,10 @@ export function SimFeatureToggles({ if (nt !== initial.nt) featurePayload.networkType = nt; if (Object.keys(featurePayload).length > 0) { - await apiClient.post( - `/subscriptions/${subscriptionId}/sim/features`, - featurePayload - ); + await apiClient.POST("/subscriptions/{id}/sim/features", { + params: { path: { id: subscriptionId } }, + body: featurePayload, + }); } setSuccess("Changes submitted successfully"); diff --git a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx index 6d762193..741dbf39 100644 --- a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx @@ -30,24 +30,31 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro try { setError(null); - const data = await apiClient.get<{ - details: SimDetails; - usage: SimUsage; - }>(`/subscriptions/${subscriptionId}/sim`); + const response = await apiClient.GET("/subscriptions/{id}/sim", { + params: { path: { id: subscriptionId } }, + }); - setSimInfo(data); + const payload = response.data as SimInfo | undefined; + + if (!payload) { + throw new Error("Failed to load SIM information"); + } + + setSimInfo(payload); } catch (err: unknown) { - const hasStatus = (v: unknown): v is { status: number } => - typeof v === "object" && - v !== null && - "status" in v && - typeof (v as { status: unknown }).status === "number"; + const hasStatus = (value: unknown): value is { status: number } => + typeof value === "object" && + value !== null && + "status" in value && + typeof (value as { status: unknown }).status === "number"; + if (hasStatus(err) && err.status === 400) { // Not a SIM subscription - this component shouldn't be shown setError("This subscription is not a SIM service"); - } else { - setError(err instanceof Error ? err.message : "Failed to load SIM information"); + return; } + + setError(err instanceof Error ? err.message : "Failed to load SIM information"); } finally { setLoading(false); } diff --git a/apps/portal/src/features/sim-management/components/TopUpModal.tsx b/apps/portal/src/features/sim-management/components/TopUpModal.tsx index 81d95c7d..da0b7bbc 100644 --- a/apps/portal/src/features/sim-management/components/TopUpModal.tsx +++ b/apps/portal/src/features/sim-management/components/TopUpModal.tsx @@ -45,7 +45,10 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU quotaMb: getCurrentAmountMb(), }; - await apiClient.post(`/subscriptions/${subscriptionId}/sim/top-up`, requestBody); + await apiClient.POST("/subscriptions/{id}/sim/top-up", { + params: { path: { id: subscriptionId } }, + body: requestBody, + }); onSuccess(); } catch (error: unknown) { diff --git a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts index 32ac755d..7c84dc5a 100644 --- a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts +++ b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts @@ -4,7 +4,7 @@ */ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useAuthStore } from "@/features/auth/services/auth.store"; +import { useAuthSession } from "@/features/auth/services/auth.store"; import { apiClient } from "@/core/api"; import type { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/domain"; @@ -17,26 +17,29 @@ interface UseSubscriptionsOptions { */ export function useSubscriptions(options: UseSubscriptionsOptions = {}) { const { status } = options; - const { tokens, isAuthenticated } = useAuthStore(); + const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery({ queryKey: ["subscriptions", status], queryFn: async () => { - if (!tokens?.accessToken) { + if (!hasValidToken) { throw new Error("Authentication required"); } - const params = new URLSearchParams({ - ...(status && { status }), - }); - const res = await apiClient.get( - `/subscriptions?${params}` + const response = await apiClient.GET( + "/subscriptions", + status ? { params: { query: { status } } } : undefined ); - return res.data as SubscriptionList | Subscription[]; + + if (!response.data) { + return [] as Subscription[]; + } + + return response.data as SubscriptionList | Subscription[]; }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -44,21 +47,21 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) { * Hook to fetch active subscriptions only */ export function useActiveSubscriptions() { - const { tokens, isAuthenticated } = useAuthStore(); + const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery({ queryKey: ["subscriptions", "active"], queryFn: async () => { - if (!tokens?.accessToken) { + if (!hasValidToken) { throw new Error("Authentication required"); } - const res = await apiClient.get(`/subscriptions/active`); - return res.data as Subscription[]; + const response = await apiClient.GET("/subscriptions/active"); + return (response.data ?? []) as Subscription[]; }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -66,7 +69,7 @@ export function useActiveSubscriptions() { * Hook to fetch subscription statistics */ export function useSubscriptionStats() { - const { tokens, isAuthenticated } = useAuthStore(); + const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery<{ total: number; @@ -77,18 +80,18 @@ export function useSubscriptionStats() { }>({ queryKey: ["subscriptions", "stats"], queryFn: async () => { - if (!tokens?.accessToken) { + if (!hasValidToken) { throw new Error("Authentication required"); } - const res = await apiClient.get<{ - total: number; - active: number; - suspended: number; - cancelled: number; - pending: number; - }>(`/subscriptions/stats`); - return res.data as { + const response = await apiClient.GET("/subscriptions/stats"); + return (response.data ?? { + total: 0, + active: 0, + suspended: 0, + cancelled: 0, + pending: 0, + }) as { total: number; active: number; suspended: number; @@ -98,7 +101,7 @@ export function useSubscriptionStats() { }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -106,21 +109,28 @@ export function useSubscriptionStats() { * Hook to fetch a specific subscription */ export function useSubscription(subscriptionId: number) { - const { tokens, isAuthenticated } = useAuthStore(); + const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery({ queryKey: ["subscription", subscriptionId], queryFn: async () => { - if (!tokens?.accessToken) { + if (!hasValidToken) { throw new Error("Authentication required"); } - const res = await apiClient.get(`/subscriptions/${subscriptionId}`); - return res.data as Subscription; + const response = await apiClient.GET("/subscriptions/{id}", { + params: { path: { id: subscriptionId } }, + }); + + if (!response.data) { + throw new Error("Subscription not found"); + } + + return response.data as Subscription; }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -132,27 +142,36 @@ export function useSubscriptionInvoices( options: { page?: number; limit?: number } = {} ) { const { page = 1, limit = 10 } = options; - const { tokens, isAuthenticated } = useAuthStore(); + const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery({ queryKey: ["subscription-invoices", subscriptionId, page, limit], queryFn: async () => { - if (!tokens?.accessToken) { + if (!hasValidToken) { throw new Error("Authentication required"); } - const params = new URLSearchParams({ - page: page.toString(), - limit: limit.toString(), + const response = await apiClient.GET("/subscriptions/{id}/invoices", { + params: { + path: { id: subscriptionId }, + query: { page, limit }, + }, }); - const res = await apiClient.get( - `/subscriptions/${subscriptionId}/invoices?${params}` - ); - return unwrap(res) as InvoiceList; + + return ( + response.data ?? { + invoices: [], + pagination: { + page, + totalPages: 0, + totalItems: 0, + }, + } + ) as InvoiceList; }, staleTime: 60 * 1000, // 1 minute gcTime: 5 * 60 * 1000, // 5 minutes - enabled: isAuthenticated && !!tokens?.accessToken && !!subscriptionId, + enabled: isAuthenticated && hasValidToken && !!subscriptionId, }); } @@ -164,8 +183,10 @@ export function useSubscriptionAction() { return useMutation({ mutationFn: async ({ id, action }: { id: number; action: string }) => { - const res = await apiClient.post(`/subscriptions/${id}/actions`, { action }); - return unwrap(res); + await apiClient.POST("/subscriptions/{id}/actions", { + params: { path: { id } }, + body: { action }, + }); }, onSuccess: (_, { id }) => { // Invalidate relevant queries after successful action diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 520cf73c..a9ba5fb9 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,6 +1,7 @@ -// Re-export generated client and types export * as ApiTypes from "./__generated__/types"; export { createClient } from "./runtime/client"; -export type { ApiClient } from "./runtime/client"; - - +export type { + ApiClient, + AuthHeaderResolver, + CreateClientOptions, +} from "./runtime/client"; diff --git a/packages/api-client/src/runtime/client.ts b/packages/api-client/src/runtime/client.ts index bf4d87af..cf638b1a 100644 --- a/packages/api-client/src/runtime/client.ts +++ b/packages/api-client/src/runtime/client.ts @@ -1,12 +1,45 @@ -// Defer importing openapi-fetch until codegen deps are installed. -import createFetchClient from "openapi-fetch"; +import createOpenApiClient from "openapi-fetch"; import type { paths } from "../__generated__/types"; -export type ApiClient = ReturnType>; +export type ApiClient = ReturnType>; -export function createClient(baseUrl: string, _getAuthHeader?: () => string | undefined): ApiClient { - // Consumers can pass headers per call using the client's request options. - return createFetchClient({ baseUrl }); +export type AuthHeaderResolver = () => string | undefined; + +export interface CreateClientOptions { + getAuthHeader?: AuthHeaderResolver; } +export function createClient( + baseUrl: string, + options: CreateClientOptions = {} +): ApiClient { + const client = createOpenApiClient({ + baseUrl, + throwOnError: true, + }); + if (typeof client.use === "function" && options.getAuthHeader) { + const resolveAuthHeader = options.getAuthHeader; + + client.use({ + onRequest({ request }: { request: Request }) { + if (!request || typeof request.headers?.has !== "function") { + return; + } + + if (request.headers.has("Authorization")) { + return; + } + + const headerValue = resolveAuthHeader(); + if (!headerValue) { + return; + } + + request.headers.set("Authorization", headerValue); + }, + } as never); + } + + return client; +}