diff --git a/apps/portal/src/core/api/client.ts b/apps/portal/src/core/api/client.ts index 02ef48c8..0ebc92b0 100644 --- a/apps/portal/src/core/api/client.ts +++ b/apps/portal/src/core/api/client.ts @@ -1,174 +1,27 @@ -/** - * Core API Client - * Instantiates the shared OpenAPI client with portal-specific configuration. + + * Core API client configuration + * Wraps the shared generated client to inject portal-specific behavior like auth headers. */ -import { createClient as createOpenApiClient } from "@customer-portal/api-client"; -import type { ApiClient as GeneratedApiClient } 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"; -const DEFAULT_JSON_CONTENT_TYPE = "application/json"; - -export type AuthHeaderGetter = () => string | undefined; - -type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - -export type ApiRequestOptions = Omit & { - body?: unknown; - headers?: HeadersInit; -}; - -export interface HttpError extends Error { - status: number; - data?: unknown; -} - -type RestMethods = { - request: (method: HttpMethod, path: string, options?: ApiRequestOptions) => Promise; - get: (path: string, options?: Omit) => Promise; - post: ( - path: string, - body?: B, - options?: Omit - ) => Promise; - put: ( - path: string, - body?: B, - options?: Omit - ) => Promise; - patch: ( - path: string, - body?: B, - options?: Omit - ) => Promise; - delete: ( - path: string, - options?: Omit & { body?: B } - ) => Promise; -}; - -export type ApiClient = GeneratedApiClient & RestMethods; - const baseUrl = env.NEXT_PUBLIC_API_BASE; -let authHeaderGetter: AuthHeaderGetter | undefined; +let authHeaderResolver: AuthHeaderResolver | undefined; -const resolveAuthHeader = () => authHeaderGetter?.(); +const resolveAuthHeader: AuthHeaderResolver = () => authHeaderResolver?.(); -const joinUrl = (base: string, path: string) => { - if (/^https?:\/\//.test(path)) { - return path; - } - - const trimmedBase = base.endsWith("/") ? base.slice(0, -1) : base; - const normalizedPath = path.startsWith("/") ? path : `/${path}`; - return `${trimmedBase}${normalizedPath}`; -}; - -const applyAuthHeader = (headersInit?: HeadersInit) => { - const headers = - headersInit instanceof Headers ? headersInit : new Headers(headersInit ?? undefined); - - const headerValue = resolveAuthHeader(); - - if (headerValue && !headers.has("Authorization")) { - headers.set("Authorization", headerValue); - } - - return headers; -}; - -const serializeBody = (body: unknown, headers: Headers): BodyInit | undefined => { - if (body === undefined || body === null) { - return undefined; - } - - if ( - body instanceof FormData || - body instanceof Blob || - body instanceof URLSearchParams || - typeof body === "string" - ) { - return body; - } - - if (!headers.has("Content-Type")) { - headers.set("Content-Type", DEFAULT_JSON_CONTENT_TYPE); - } - - return JSON.stringify(body); -}; - -const parseResponseBody = async (response: Response) => { - if (response.status === 204) { - return undefined; - } - - const text = await response.text(); - if (!text) { - return undefined; - } - - try { - return JSON.parse(text) as unknown; - } catch { - return text; - } -}; - -const createHttpError = (response: Response, data: unknown): HttpError => { - const message = - typeof data === "object" && data !== null && "message" in data && - typeof (data as Record).message === "string" - ? (data as { message: string }).message - : `Request failed with status ${response.status}`; - - const error = new Error(message) as HttpError; - error.status = response.status; - if (data !== undefined) { - error.data = data; - } - return error; -}; - -const request = async ( - method: HttpMethod, - path: string, - options: ApiRequestOptions = {} -): Promise => { - const { body, headers: headersInit, ...rest } = options; - const headers = applyAuthHeader(headersInit); - const serializedBody = serializeBody(body, headers); - const response = await fetch(joinUrl(baseUrl, path), { - ...rest, - method, - headers, - body: serializedBody, - }); - - const parsedBody = await parseResponseBody(response); - if (!response.ok) { - throw createHttpError(response, parsedBody); - } - - return parsedBody as T; -}; - -const restMethods: RestMethods = { - request, - get: (path, options) => request("GET", path, options as ApiRequestOptions), - post: (path, body, options) => request("POST", path, { ...options, body }), - put: (path, body, options) => request("PUT", path, { ...options, body }), - patch: (path, body, options) => request("PATCH", path, { ...options, body }), - delete: (path, options) => request("DELETE", path, options as ApiRequestOptions), -}; - -const openApiClient = createOpenApiClient(baseUrl, { +export const apiClient: GeneratedApiClient = createOpenApiClient(baseUrl, { getAuthHeader: resolveAuthHeader, }); -export const apiClient = Object.assign(openApiClient, restMethods) as ApiClient; - -export const configureApiClientAuth = (getter?: AuthHeaderGetter) => { - authHeaderGetter = getter; +export const configureApiClientAuth = (resolver?: AuthHeaderResolver) => { + authHeaderResolver = resolver; }; + +export type ApiClient = GeneratedApiClient; \ No newline at end of file diff --git a/apps/portal/src/core/api/index.ts b/apps/portal/src/core/api/index.ts index e3d9e55d..6289dcd7 100644 --- a/apps/portal/src/core/api/index.ts +++ b/apps/portal/src/core/api/index.ts @@ -1,3 +1,5 @@ export { apiClient, configureApiClientAuth } from "./client"; export { queryKeys } from "./query-keys"; -export type { ApiClient, ApiRequestOptions, AuthHeaderGetter, HttpError } from "./client"; + +export type { ApiClient } from "./client"; +export type { AuthHeaderResolver } from "@customer-portal/api-client"; diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index af1e644c..3fe281a0 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -343,7 +343,16 @@ export const useAuthStore = create()( if (user) { set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true }); } else { - set({ loading: false, hasCheckedAuth: true }); + // No user data returned, clear auth state + set({ + user: null, + tokens: null, + isAuthenticated: false, + loading: false, + error: null, + hasCheckedAuth: true, + }); + } } catch (error) { // Token is invalid, clear auth state 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 d2cc49e1..807d920b 100644 --- a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts +++ b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts @@ -22,14 +22,22 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) { return useQuery({ queryKey: ["subscriptions", status], queryFn: async () => { + const params = new URLSearchParams({ ...(status && { status }), }); - if (!hasValidToken) { - throw new Error("Authentication required"); + + + const response = await apiClient.GET( + "/subscriptions", + status ? { params: { query: { status } } } : undefined + ); + + if (!response.data) { + return [] as Subscription[]; } - return apiClient.get(`/subscriptions?${params}`); + return response.data as SubscriptionList | Subscription[]; }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes @@ -50,7 +58,9 @@ export function useActiveSubscriptions() { throw new Error("Authentication required"); } - return apiClient.get(`/subscriptions/active`); + + const response = await apiClient.GET("/subscriptions/active"); + return (response.data ?? []) as Subscription[]; }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes @@ -77,14 +87,25 @@ export function useSubscriptionStats() { throw new Error("Authentication required"); } - const stats = await apiClient.get<{ + + 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; cancelled: number; pending: number; - }>(`/subscriptions/stats`); - return stats; + + }; + }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes @@ -105,7 +126,17 @@ export function useSubscription(subscriptionId: number) { throw new Error("Authentication required"); } - return apiClient.get(`/subscriptions/${subscriptionId}`); + + 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 @@ -130,13 +161,24 @@ export function useSubscriptionInvoices( 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 }, + }, }); - return apiClient.get( - `/subscriptions/${subscriptionId}/invoices?${params}` - ); + + return ( + response.data ?? { + invoices: [], + pagination: { + page, + totalPages: 0, + totalItems: 0, + }, + } + ) as InvoiceList; + }, staleTime: 60 * 1000, // 1 minute gcTime: 5 * 60 * 1000, // 5 minutes @@ -152,7 +194,11 @@ export function useSubscriptionAction() { return useMutation({ mutationFn: async ({ id, action }: { id: number; action: string }) => { - return apiClient.post(`/subscriptions/${id}/actions`, { action }); + 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/runtime/client.ts b/packages/api-client/src/runtime/client.ts index 503e4211..21f33a1c 100644 --- a/packages/api-client/src/runtime/client.ts +++ b/packages/api-client/src/runtime/client.ts @@ -13,7 +13,11 @@ export function createClient( baseUrl: string, options: CreateClientOptions = {} ): ApiClient { - const client = createOpenApiClient({ baseUrl }); + const client = createOpenApiClient({ + baseUrl, + throwOnError: true, + }); + if (typeof client.use === "function" && options.getAuthHeader) { const resolveAuthHeader = options.getAuthHeader;