diff --git a/apps/portal/src/core/api/client.ts b/apps/portal/src/core/api/client.ts index 946a24da..02ef48c8 100644 --- a/apps/portal/src/core/api/client.ts +++ b/apps/portal/src/core/api/client.ts @@ -1,13 +1,174 @@ /** * Core API Client - * Minimal API client setup using the generated OpenAPI client + * Instantiates the shared OpenAPI client with portal-specific configuration. */ -import { createClient } from "@customer-portal/api-client"; +import { createClient as createOpenApiClient } from "@customer-portal/api-client"; +import type { ApiClient as GeneratedApiClient } 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 DEFAULT_JSON_CONTENT_TYPE = "application/json"; -// Export the client type for use in hooks -export type { ApiClient } from "@customer-portal/api-client"; +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; + +const resolveAuthHeader = () => authHeaderGetter?.(); + +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, { + getAuthHeader: resolveAuthHeader, +}); + +export const apiClient = Object.assign(openApiClient, restMethods) as ApiClient; + +export const configureApiClientAuth = (getter?: AuthHeaderGetter) => { + authHeaderGetter = getter; +}; diff --git a/apps/portal/src/core/api/index.ts b/apps/portal/src/core/api/index.ts index e3689269..e3d9e55d 100644 --- a/apps/portal/src/core/api/index.ts +++ b/apps/portal/src/core/api/index.ts @@ -1,3 +1,3 @@ -export { apiClient } from "./client"; +export { apiClient, configureApiClientAuth } from "./client"; export { queryKeys } from "./query-keys"; -export type { ApiClient } from "./client"; +export type { ApiClient, ApiRequestOptions, AuthHeaderGetter, HttpError } from "./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/subscriptions/hooks/useSubscriptions.ts b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts index 32ac755d..d2cc49e1 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,23 @@ 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) { - throw new Error("Authentication required"); - } - const params = new URLSearchParams({ ...(status && { status }), }); - const res = await apiClient.get( - `/subscriptions?${params}` - ); - return res.data as SubscriptionList | Subscription[]; + if (!hasValidToken) { + throw new Error("Authentication required"); + } + + return apiClient.get(`/subscriptions?${params}`); }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -44,21 +41,20 @@ 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[]; + return apiClient.get(`/subscriptions/active`); }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -66,7 +62,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,28 +73,22 @@ export function useSubscriptionStats() { }>({ queryKey: ["subscriptions", "stats"], queryFn: async () => { - if (!tokens?.accessToken) { + if (!hasValidToken) { throw new Error("Authentication required"); } - const res = await apiClient.get<{ + const stats = await apiClient.get<{ total: number; active: number; suspended: number; cancelled: number; pending: number; }>(`/subscriptions/stats`); - return res.data as { - total: number; - active: number; - suspended: number; - cancelled: number; - pending: number; - }; + return stats; }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -106,21 +96,20 @@ 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; + return apiClient.get(`/subscriptions/${subscriptionId}`); }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -132,12 +121,12 @@ 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"); } @@ -145,14 +134,13 @@ export function useSubscriptionInvoices( page: page.toString(), limit: limit.toString(), }); - const res = await apiClient.get( + return apiClient.get( `/subscriptions/${subscriptionId}/invoices?${params}` ); - return unwrap(res) as InvoiceList; }, staleTime: 60 * 1000, // 1 minute gcTime: 5 * 60 * 1000, // 5 minutes - enabled: isAuthenticated && !!tokens?.accessToken && !!subscriptionId, + enabled: isAuthenticated && hasValidToken && !!subscriptionId, }); } @@ -164,8 +152,7 @@ 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); + return apiClient.post(`/subscriptions/${id}/actions`, { 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..503e4211 100644 --- a/packages/api-client/src/runtime/client.ts +++ b/packages/api-client/src/runtime/client.ts @@ -1,12 +1,42 @@ -// 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 }); + 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; +}