127 lines
3.8 KiB
TypeScript
127 lines
3.8 KiB
TypeScript
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
|
|
|
interface AuthTokens {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
}
|
|
|
|
interface AuthResponse {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
user: { id: number; email: string; role: string; name?: string };
|
|
}
|
|
|
|
export class ApiClient {
|
|
private client: AxiosInstance;
|
|
private tokens: AuthTokens | null = null;
|
|
private refreshPromise: Promise<AuthTokens> | null = null;
|
|
private baseUrl: string;
|
|
private email: string;
|
|
private password: string;
|
|
|
|
constructor(config: { baseUrl: string; email: string; password: string }) {
|
|
this.baseUrl = config.baseUrl;
|
|
this.email = config.email;
|
|
this.password = config.password;
|
|
|
|
this.client = axios.create({
|
|
baseURL: config.baseUrl,
|
|
timeout: 30_000,
|
|
headers: { 'X-MCP-Session': 'true' },
|
|
});
|
|
|
|
// Attach access token to every request
|
|
this.client.interceptors.request.use((req: InternalAxiosRequestConfig) => {
|
|
if (this.tokens?.accessToken) {
|
|
req.headers.Authorization = `Bearer ${this.tokens.accessToken}`;
|
|
}
|
|
return req;
|
|
});
|
|
|
|
// Auto-refresh on 401 (mirrors admin/src/lib/api.ts:39-83)
|
|
this.client.interceptors.response.use(
|
|
(res) => res,
|
|
async (error: AxiosError) => {
|
|
const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
|
const errorCode = (error.response?.data as any)?.error?.code;
|
|
|
|
if (
|
|
error.response?.status === 401 &&
|
|
(errorCode === 'INVALID_TOKEN' || errorCode === 'AUTH_REQUIRED') &&
|
|
!original._retry
|
|
) {
|
|
original._retry = true;
|
|
|
|
if (!this.tokens?.refreshToken) {
|
|
throw new Error('No refresh token available — call login() first');
|
|
}
|
|
|
|
try {
|
|
if (!this.refreshPromise) {
|
|
this.refreshPromise = this.doRefresh(this.tokens.refreshToken);
|
|
}
|
|
this.tokens = await this.refreshPromise;
|
|
this.refreshPromise = null;
|
|
|
|
original.headers.Authorization = `Bearer ${this.tokens.accessToken}`;
|
|
return this.client(original);
|
|
} catch {
|
|
this.refreshPromise = null;
|
|
// Refresh failed — try full re-login
|
|
await this.login();
|
|
original.headers.Authorization = `Bearer ${this.tokens!.accessToken}`;
|
|
return this.client(original);
|
|
}
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
);
|
|
}
|
|
|
|
/** Authenticate with the API and store tokens */
|
|
async login(): Promise<void> {
|
|
const res = await axios.post<AuthResponse>(
|
|
`${this.baseUrl}/api/auth/login`,
|
|
{ email: this.email, password: this.password },
|
|
{ headers: { 'X-MCP-Session': 'true' } }
|
|
);
|
|
this.tokens = {
|
|
accessToken: res.data.accessToken,
|
|
refreshToken: res.data.refreshToken,
|
|
};
|
|
}
|
|
|
|
private async doRefresh(refreshToken: string): Promise<AuthTokens> {
|
|
const res = await axios.post<AuthResponse>(
|
|
`${this.baseUrl}/api/auth/refresh`,
|
|
{ refreshToken },
|
|
{ headers: { 'X-MCP-Session': 'true' } }
|
|
);
|
|
return {
|
|
accessToken: res.data.accessToken,
|
|
refreshToken: res.data.refreshToken,
|
|
};
|
|
}
|
|
|
|
/** Make an authenticated API request */
|
|
async request<T = any>(
|
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
|
|
path: string,
|
|
data?: Record<string, unknown>
|
|
): Promise<T> {
|
|
const isBodyMethod = method === 'POST' || method === 'PUT' || method === 'PATCH';
|
|
const res = await this.client.request<T>({
|
|
method,
|
|
url: path,
|
|
...(isBodyMethod ? { data } : { params: data }),
|
|
});
|
|
return res.data;
|
|
}
|
|
|
|
/** Check if we have valid tokens (not necessarily unexpired) */
|
|
get isAuthenticated(): boolean {
|
|
return this.tokens !== null;
|
|
}
|
|
}
|