refactor: simplify api client auth integration
This commit is contained in:
parent
a22b84f128
commit
3242f49ce7
@ -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<RequestInit, "method" | "body" | "headers"> & {
|
||||
body?: unknown;
|
||||
headers?: HeadersInit;
|
||||
};
|
||||
|
||||
export interface HttpError extends Error {
|
||||
status: number;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
type RestMethods = {
|
||||
request: <T = unknown>(method: HttpMethod, path: string, options?: ApiRequestOptions) => Promise<T>;
|
||||
get: <T = unknown>(path: string, options?: Omit<ApiRequestOptions, "body">) => Promise<T>;
|
||||
post: <T = unknown, B = unknown>(
|
||||
path: string,
|
||||
body?: B,
|
||||
options?: Omit<ApiRequestOptions, "body">
|
||||
) => Promise<T>;
|
||||
put: <T = unknown, B = unknown>(
|
||||
path: string,
|
||||
body?: B,
|
||||
options?: Omit<ApiRequestOptions, "body">
|
||||
) => Promise<T>;
|
||||
patch: <T = unknown, B = unknown>(
|
||||
path: string,
|
||||
body?: B,
|
||||
options?: Omit<ApiRequestOptions, "body">
|
||||
) => Promise<T>;
|
||||
delete: <T = unknown, B = unknown>(
|
||||
path: string,
|
||||
options?: Omit<ApiRequestOptions, "body"> & { body?: B }
|
||||
) => Promise<T>;
|
||||
};
|
||||
|
||||
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<string, unknown>).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 <T = unknown>(
|
||||
method: HttpMethod,
|
||||
path: string,
|
||||
options: ApiRequestOptions = {}
|
||||
): Promise<T> => {
|
||||
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;
|
||||
};
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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<typeof user>;
|
||||
useAuthStore.setState(state => ({
|
||||
...state,
|
||||
user: state.user ? { ...state.user, ...updatedUser } : state.user,
|
||||
|
||||
37
apps/portal/src/features/account/services/account.service.ts
Normal file
37
apps/portal/src/features/account/services/account.service.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { apiClient } from "@/core/api";
|
||||
import type { Address, AuthUser } from "@customer-portal/domain";
|
||||
|
||||
const ensureData = <T>(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<AuthUser | null>(response.data);
|
||||
},
|
||||
|
||||
async updateProfile(update: ProfileUpdateInput) {
|
||||
const response = await apiClient.PATCH('/me', { body: update });
|
||||
return ensureData<AuthUser>(response.data);
|
||||
},
|
||||
|
||||
async getAddress() {
|
||||
const response = await apiClient.GET('/me/address');
|
||||
return ensureData<Address | null>(response.data);
|
||||
},
|
||||
|
||||
async updateAddress(address: Address) {
|
||||
const response = await apiClient.PATCH('/me/address', { body: address });
|
||||
return ensureData<Address>(response.data);
|
||||
},
|
||||
};
|
||||
@ -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<AuthTokens, "expiresAt"> & { 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<AuthStoreState>()(
|
||||
@ -76,9 +132,10 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
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<AuthStoreState>()(
|
||||
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<AuthStoreState>()(
|
||||
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<AuthStoreState>()(
|
||||
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<AuthStoreState>()(
|
||||
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<AuthStoreState>()(
|
||||
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<AuthStoreState>()(
|
||||
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<AuthStoreState>()(
|
||||
|
||||
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<AuthStoreState>()(
|
||||
)
|
||||
);
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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<SubscriptionList | Subscription[]>({
|
||||
queryKey: ["subscriptions", status],
|
||||
queryFn: async () => {
|
||||
if (!tokens?.accessToken) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
...(status && { status }),
|
||||
});
|
||||
const res = await apiClient.get<SubscriptionList | Subscription[]>(
|
||||
`/subscriptions?${params}`
|
||||
);
|
||||
return res.data as SubscriptionList | Subscription[];
|
||||
if (!hasValidToken) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
return apiClient.get<SubscriptionList | Subscription[]>(`/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<Subscription[]>({
|
||||
queryKey: ["subscriptions", "active"],
|
||||
queryFn: async () => {
|
||||
if (!tokens?.accessToken) {
|
||||
if (!hasValidToken) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
const res = await apiClient.get<Subscription[]>(`/subscriptions/active`);
|
||||
return res.data as Subscription[];
|
||||
return apiClient.get<Subscription[]>(`/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<Subscription>({
|
||||
queryKey: ["subscription", subscriptionId],
|
||||
queryFn: async () => {
|
||||
if (!tokens?.accessToken) {
|
||||
if (!hasValidToken) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
const res = await apiClient.get<Subscription>(`/subscriptions/${subscriptionId}`);
|
||||
return res.data as Subscription;
|
||||
return apiClient.get<Subscription>(`/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<InvoiceList>({
|
||||
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<InvoiceList>(
|
||||
return apiClient.get<InvoiceList>(
|
||||
`/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
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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<typeof createFetchClient<paths>>;
|
||||
export type ApiClient = ReturnType<typeof createOpenApiClient<paths>>;
|
||||
|
||||
export function createClient(baseUrl: string, _getAuthHeader?: () => string | undefined): ApiClient {
|
||||
// Consumers can pass headers per call using the client's request options.
|
||||
return createFetchClient<paths>({ baseUrl });
|
||||
export type AuthHeaderResolver = () => string | undefined;
|
||||
|
||||
export interface CreateClientOptions {
|
||||
getAuthHeader?: AuthHeaderResolver;
|
||||
}
|
||||
|
||||
export function createClient(
|
||||
baseUrl: string,
|
||||
options: CreateClientOptions = {}
|
||||
): ApiClient {
|
||||
const client = createOpenApiClient<paths>({ 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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user