import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios' import { useAuthStore } from '../store/authStore' import type { FleetStatus, CreateJobRequest, CreateMaintenanceWindowRequest, UpdateMaintenanceWindowRequest, Certificate, IssuedCert, } from '../types' const BASE_URL = '/api/v1' export const apiClient = axios.create({ baseURL: BASE_URL, headers: { 'Content-Type': 'application/json' }, timeout: 30_000, }) // ── Request interceptor: attach access token ──────────────────────────────── apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => { const token = useAuthStore.getState().accessToken if (token && config.headers) { config.headers.Authorization = `Bearer ${token}` } return config }) // ── Response interceptor: refresh on 401 ──────────────────────────────────── let isRefreshing = false let failedQueue: Array<{ resolve: (v: string) => void; reject: (e: unknown) => void }> = [] const processQueue = (error: unknown, token: string | null) => { failedQueue.forEach(({ resolve, reject }) => { if (error) reject(error) else resolve(token!) // eslint-disable-line @typescript-eslint/no-non-null-assertion }) failedQueue = [] } apiClient.interceptors.response.use( (res) => res, async (error: AxiosError) => { const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean } if (error.response?.status !== 401 || original._retry) { return Promise.reject(error) } if (isRefreshing) { return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }) }).then((token) => { original.headers.Authorization = `Bearer ${token}` return apiClient(original) }) } original._retry = true isRefreshing = true const { refreshToken, setTokens, logout } = useAuthStore.getState() if (!refreshToken) { logout() window.location.href = '/login' return Promise.reject(error) } try { const { data } = await axios.post(`${BASE_URL}/auth/refresh`, { refresh_token: refreshToken, }) setTokens(data.access_token, data.refresh_token) processQueue(null, data.access_token) original.headers.Authorization = `Bearer ${data.access_token}` return apiClient(original) } catch (refreshError) { processQueue(refreshError, null) logout() window.location.href = '/login' return Promise.reject(refreshError) } finally { isRefreshing = false } } ) // ── Auth API functions ─────────────────────────────────────────────────────── export const authApi = { login: (username: string, password: string, totpCode?: string) => apiClient.post('/auth/login', { username, password, totp_code: totpCode }), logout: (refreshToken: string) => apiClient.post('/auth/logout', { refresh_token: refreshToken }), getMfaSetup: () => apiClient.get('/auth/mfa/setup'), verifyMfa: (secretBase32: string, code: string) => apiClient.post('/auth/mfa/verify', { secret_base32: secretBase32, code }), } // ── Fleet API functions ────────────────────────────────────────────────────── export const fleetApi = { getStatus: () => apiClient.get('/status/fleet'), } // ── Hosts API functions ────────────────────────────────────────────────────── export const hostsApi = { list: (params?: Record) => apiClient.get('/hosts', { params }), get: (id: string) => apiClient.get(`/hosts/${id}`), delete: (id: string) => apiClient.delete(`/hosts/${id}`), refresh: (id: string) => apiClient.post(`/hosts/${id}/refresh`), } // ── Jobs API ───────────────────────────────────────────────────────────────── export const jobsApi = { list: (params?: Record) => apiClient.get('/jobs', { params }), get: (id: string) => apiClient.get(`/jobs/${id}`), create: (body: CreateJobRequest) => apiClient.post('/jobs', body), cancel: (id: string) => apiClient.post(`/jobs/${id}/cancel`), rollback: (id: string) => apiClient.post(`/jobs/${id}/rollback`), } // ── Patches API (per-host patch listing) ────────────────────────────────────── export const patchesApi = { // Returns patches available on a specific host via the manager's proxy // The backend reads from host_patch_data table (cached from agent poll) getHostPatches: (hostId: string) => apiClient.get(`/hosts/${hostId}/patches`), } // ── Maintenance Windows API ─────────────────────────────────────────────────── export const maintenanceWindowsApi = { list: (hostId: string) => apiClient.get(`/hosts/${hostId}/maintenance-windows`), create: (hostId: string, body: CreateMaintenanceWindowRequest) => apiClient.post(`/hosts/${hostId}/maintenance-windows`, body), update: (hostId: string, windowId: string, body: UpdateMaintenanceWindowRequest) => apiClient.put(`/hosts/${hostId}/maintenance-windows/${windowId}`, body), remove: (hostId: string, windowId: string) => apiClient.delete(`/hosts/${hostId}/maintenance-windows/${windowId}`), } // ── WebSocket API (M7) ──────────────────────────────────────────────────────── export const wsApi = { /** POST /api/v1/ws/ticket — obtain a single-use WS auth ticket (60 s expiry). */ createTicket: (): Promise<{ ticket: string }> => apiClient.post<{ ticket: string }>('/ws/ticket').then((r) => r.data), } // ── Certificates API (M8) ──────────────────────────────────────────────────── export const certsApi = { // List all certs, optional filters list: (params?: { host_id?: string; status?: string }) => apiClient.get('/certificates', { params }), // Download root CA cert as blob downloadRootCa: () => apiClient.get('/ca/root.crt', { responseType: 'blob' }), // Issue client cert for a host — returns IssuedCert (key_pem only shown once!) issue: (hostId: string, hostname: string) => apiClient.post(`/hosts/${hostId}/certificates`, { hostname }), // Renew a cert renew: (certId: string) => apiClient.post(`/certificates/${certId}/renew`), // Revoke a cert revoke: (certId: string) => apiClient.delete(`/certificates/${certId}`), // Download host client cert as blob downloadClientCert: (hostId: string) => apiClient.get(`/hosts/${hostId}/client.crt`, { responseType: 'blob' }), } // ── Reports API (M9) ───────────────────────────────────────────────────────── export type ReportType = 'compliance' | 'patch-history' | 'vulnerability' | 'audit' export type ReportFormat = 'csv' | 'pdf' export const reportsApi = { download: ( reportType: ReportType, format: ReportFormat, params?: { from?: string // ISO 8601 to?: string // ISO 8601 group_id?: string // UUID } ) => apiClient.get(`/reports/${reportType}`, { params: { format, ...params }, responseType: 'blob', timeout: 120_000, // reports can take a while }), } // ── Settings API (M10) ──────────────────────────────────────────────────── export interface AzureSsoConfig { enabled: boolean tenant_id: string client_id: string redirect_uri: string scopes: string } export interface SmtpConfig { enabled: boolean host: string port: number username: string from: string tls_mode: string } export interface PollingConfig { health_poll_interval_secs: number patch_poll_interval_secs: number } export interface NotificationConfig { email_enabled: boolean email_from: string recipients: string[] } export interface SettingsResponse { azure_sso: AzureSsoConfig smtp: SmtpConfig polling: PollingConfig ip_whitelist: string[] web_tls_strategy: string notification: NotificationConfig } export interface TestResult { success: boolean message: string } export interface AuditIntegrityResult { intact: boolean rows_checked: number errors: Array<{ row_id: number expected_hash: string actual_hash: string }> } export const settingsApi = { get: () => apiClient.get('/settings'), update: (data: Partial & { azure_sso?: AzureSsoConfig & { client_secret?: string } smtp?: SmtpConfig & { password?: string } notification?: NotificationConfig }) => apiClient.put('/settings', data), testAzureSso: () => apiClient.post('/settings/azure-sso/test'), testSmtp: () => apiClient.post('/settings/smtp/test'), getIpWhitelist: () => apiClient.get<{ entries: string[] }>('/settings/ip-whitelist'), updateIpWhitelist: (entries: string[]) => apiClient.put<{ entries: string[] }>('/settings/ip-whitelist', { entries }), auditIntegrity: () => apiClient.post('/settings/audit-integrity'), }