import { API_BASE_URL } from "./config"; import type { AlertDetailResponse, AlertListFilters, AlertResponse, AlertUpdatePayload, ApiErrorBody, BanIpPayload, CompanyCreatePayload, CompanyResponse, CompanyUpdatePayload, ConfirmPasswordResetPayload, ChangePasswordPayload, DashboardAnalytics, DbViewerSessionResponse, DeleteAccountPayload, DiscoverCompanyRequest, DiscoveredCompanyProfile, IpBan, LogEntry, LoginPayload, MeResponse, MonitorConfigurationResponse, MonitorConfigurationUpdatePayload, MonitoringRunResponse, NotificationDestinationCreatePayload, NotificationDestinationResponse, NotificationDestinationUpdatePayload, NotificationTestResult, RegisterPayload, ReportListItem, ReportResponse, RequestPasswordResetPayload, ResendVerificationPayload, SecurityEvent, SetSystemSecretPayload, SetUserApiKeyPayload, SnapshotResponse, SourceCreatePayload, SourceResponse, SourceTestResult, SourceUpdatePayload, SystemSecretStatus, SystemStatus, TokenResponse, UnbanRequestPayload, UnbanRequestRecord, UserApiKeyStatus, UserResponse, VerifyEmailPayload, } from "./types"; const ACCESS_TOKEN_KEY = "ciagent_access_token"; const REFRESH_TOKEN_KEY = "ciagent_refresh_token"; // Local-storage token persistence keeps this MVP's client simple to run // entirely outside Docker/without a backend session store. Tokens are // short-lived (see JWT_ACCESS_TOKEN_MINUTES) and refresh tokens rotate on // use; see KNOWN_LIMITATIONS.md for the httpOnly-cookie hardening this // would need before a real multi-user production deployment. export function getAccessToken(): string | null { if (typeof window === "undefined") return null; return window.localStorage.getItem(ACCESS_TOKEN_KEY); } export function getRefreshToken(): string | null { if (typeof window === "undefined") return null; return window.localStorage.getItem(REFRESH_TOKEN_KEY); } export function setTokens(accessToken: string, refreshToken: string): void { window.localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); window.localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); } export function clearTokens(): void { window.localStorage.removeItem(ACCESS_TOKEN_KEY); window.localStorage.removeItem(REFRESH_TOKEN_KEY); } export class ApiError extends Error { constructor( public status: number, public body: ApiErrorBody | undefined, ) { super(body?.detail ?? `Request failed with status ${status}`); this.name = "ApiError"; } } interface RequestOptions extends RequestInit { auth?: boolean; } // Access tokens are short-lived (JWT_ACCESS_TOKEN_MINUTES, 15min) while // refresh tokens last days (JWT_REFRESH_TOKEN_DAYS, 7) - without this, any // idle period past 15 minutes turned every authenticated request into a // hard 401, which bounced the dashboard layout to /login while /login's own // stale-cache "am I logged in?" check bounced right back, flickering // between the two. A single in-flight refresh is shared across concurrent // 401s so simultaneous requests don't each rotate (and invalidate) the // refresh token out from under one another. let refreshPromise: Promise | null = null; async function performRefresh(): Promise { const refreshToken = getRefreshToken(); if (!refreshToken) { throw new ApiError(401, { detail: "No refresh token available" }); } const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!response.ok) { clearTokens(); throw new ApiError(response.status, undefined); } const tokens = (await response.json()) as TokenResponse; setTokens(tokens.access_token, tokens.refresh_token); } async function request(path: string, options: RequestOptions = {}, isRetry = false): Promise { const { auth = true, headers, ...rest } = options; const finalHeaders = new Headers(headers); finalHeaders.set("Content-Type", "application/json"); if (auth) { const token = getAccessToken(); if (token) finalHeaders.set("Authorization", `Bearer ${token}`); } const response = await fetch(`${API_BASE_URL}${path}`, { ...rest, headers: finalHeaders }); if (response.status === 401 && auth && !isRetry && getRefreshToken()) { try { refreshPromise ??= performRefresh().finally(() => { refreshPromise = null; }); await refreshPromise; return request(path, options, true); } catch { // Refresh itself failed (refresh token also expired/revoked) - fall // through and report the original 401 below, which is now a real, // final "you're actually logged out" rather than a transient one. } } if (!response.ok) { let body: ApiErrorBody | undefined; try { body = await response.json(); } catch { body = undefined; } throw new ApiError(response.status, body); } if (response.status === 204) { return undefined as T; } return (await response.json()) as T; } export const api = { systemStatus: () => request("/api/v1/system/status", { auth: false }), systemLogs: () => request("/api/v1/system/logs"), listSystemSecrets: () => request("/api/v1/system/secrets"), createDbViewerSession: () => request("/api/v1/db-viewer/session", { method: "POST" }), setSystemSecret: (key: string, payload: SetSystemSecretPayload) => request(`/api/v1/system/secrets/${key}`, { method: "PUT", body: JSON.stringify(payload), }), register: (payload: RegisterPayload) => request("/api/v1/auth/register", { method: "POST", body: JSON.stringify(payload), auth: false, }), login: (payload: LoginPayload) => request("/api/v1/auth/login", { method: "POST", body: JSON.stringify(payload), auth: false, }), refresh: (refreshToken: string) => request("/api/v1/auth/refresh", { method: "POST", body: JSON.stringify({ refresh_token: refreshToken }), auth: false, }), logout: (refreshToken: string) => request("/api/v1/auth/logout", { method: "POST", body: JSON.stringify({ refresh_token: refreshToken }), }), me: () => request("/api/v1/auth/me"), deleteAccount: (payload: DeleteAccountPayload) => request("/api/v1/auth/me", { method: "DELETE", body: JSON.stringify(payload), }), changePassword: (payload: ChangePasswordPayload) => request("/api/v1/auth/change-password", { method: "POST", body: JSON.stringify(payload), }), verifyEmail: (payload: VerifyEmailPayload) => request("/api/v1/auth/verify-email", { method: "POST", body: JSON.stringify(payload), auth: false, }), resendVerification: (payload: ResendVerificationPayload) => request("/api/v1/auth/resend-verification", { method: "POST", body: JSON.stringify(payload), auth: false, }), requestPasswordReset: (payload: RequestPasswordResetPayload) => request("/api/v1/auth/request-password-reset", { method: "POST", body: JSON.stringify(payload), auth: false, }), confirmPasswordReset: (payload: ConfirmPasswordResetPayload) => request("/api/v1/auth/confirm-password-reset", { method: "POST", body: JSON.stringify(payload), auth: false, }), securityEvents: () => request("/api/v1/auth/security-events"), submitUnbanRequest: (payload: UnbanRequestPayload) => request("/api/v1/unban-requests", { method: "POST", body: JSON.stringify(payload), auth: false, }), listIpBans: () => request("/api/v1/admin/ip-bans"), createIpBan: (payload: BanIpPayload) => request("/api/v1/admin/ip-bans", { method: "POST", body: JSON.stringify(payload), }), deleteIpBan: (ipAddress: string) => request(`/api/v1/admin/ip-bans/${encodeURIComponent(ipAddress)}`, { method: "DELETE", }), listUnbanRequests: () => request("/api/v1/admin/unban-requests"), acceptUnbanRequest: (requestId: string) => request(`/api/v1/admin/unban-requests/${requestId}/accept`, { method: "POST" }), rejectUnbanRequest: (requestId: string) => request(`/api/v1/admin/unban-requests/${requestId}`, { method: "DELETE" }), listUserApiKeys: () => request("/api/v1/user-api-keys"), setUserApiKey: (provider: string, payload: SetUserApiKeyPayload) => request(`/api/v1/user-api-keys/${provider}`, { method: "PUT", body: JSON.stringify(payload), }), listCompanies: () => request("/api/v1/companies"), discoverCompany: (payload: DiscoverCompanyRequest) => request("/api/v1/companies/discover", { method: "POST", body: JSON.stringify(payload), }), getCompany: (companyId: string) => request(`/api/v1/companies/${companyId}`), createCompany: (payload: CompanyCreatePayload) => request("/api/v1/companies", { method: "POST", body: JSON.stringify(payload), }), updateCompany: (companyId: string, payload: CompanyUpdatePayload) => request(`/api/v1/companies/${companyId}`, { method: "PATCH", body: JSON.stringify(payload), }), deleteCompany: (companyId: string) => request(`/api/v1/companies/${companyId}`, { method: "DELETE" }), pauseCompany: (companyId: string) => request(`/api/v1/companies/${companyId}/pause`, { method: "POST" }), resumeCompany: (companyId: string) => request(`/api/v1/companies/${companyId}/resume`, { method: "POST" }), updateMonitorConfiguration: (companyId: string, payload: MonitorConfigurationUpdatePayload) => request(`/api/v1/companies/${companyId}/monitor`, { method: "PATCH", body: JSON.stringify(payload), }), listNotificationDestinations: () => request("/api/v1/notification-destinations"), createNotificationDestination: (payload: NotificationDestinationCreatePayload) => request("/api/v1/notification-destinations", { method: "POST", body: JSON.stringify(payload), }), updateNotificationDestination: ( destinationId: string, payload: NotificationDestinationUpdatePayload, ) => request(`/api/v1/notification-destinations/${destinationId}`, { method: "PATCH", body: JSON.stringify(payload), }), deleteNotificationDestination: (destinationId: string) => request(`/api/v1/notification-destinations/${destinationId}`, { method: "DELETE" }), unlinkNotificationDestinationCompany: (destinationId: string, companyId: string) => request(`/api/v1/notification-destinations/${destinationId}/companies/${companyId}`, { method: "DELETE", }), testNotificationDestination: (destinationId: string) => request(`/api/v1/notification-destinations/${destinationId}/test`, { method: "POST", }), listAlerts: (filters: AlertListFilters = {}) => { const params = new URLSearchParams(); if (filters.company_id) params.set("company_id", filters.company_id); if (filters.severity) params.set("severity", filters.severity); if (filters.read !== undefined) params.set("read", String(filters.read)); if (filters.resolved !== undefined) params.set("resolved", String(filters.resolved)); const query = params.toString(); return request(`/api/v1/alerts${query ? `?${query}` : ""}`); }, getAlert: (alertId: string) => request(`/api/v1/alerts/${alertId}`), updateAlert: (alertId: string, payload: AlertUpdatePayload) => request(`/api/v1/alerts/${alertId}`, { method: "PATCH", body: JSON.stringify(payload), }), markAlertRead: (alertId: string) => request(`/api/v1/alerts/${alertId}/read`, { method: "POST" }), resolveAlert: (alertId: string) => request(`/api/v1/alerts/${alertId}/resolve`, { method: "POST" }), getDashboardAnalytics: () => request("/api/v1/dashboard/analytics"), listSources: (companyId: string) => request(`/api/v1/companies/${companyId}/sources`), listSnapshots: (companyId: string) => request(`/api/v1/companies/${companyId}/snapshots`), createSource: (companyId: string, payload: SourceCreatePayload) => request(`/api/v1/companies/${companyId}/sources`, { method: "POST", body: JSON.stringify(payload), }), updateSource: (sourceId: string, payload: SourceUpdatePayload) => request(`/api/v1/sources/${sourceId}`, { method: "PATCH", body: JSON.stringify(payload), }), deleteSource: (sourceId: string) => request(`/api/v1/sources/${sourceId}`, { method: "DELETE" }), testSource: (sourceId: string) => request(`/api/v1/sources/${sourceId}/test`, { method: "POST" }), runCompanyNow: (companyId: string) => request(`/api/v1/companies/${companyId}/run`, { method: "POST" }), listCompanyRuns: (companyId: string) => request(`/api/v1/companies/${companyId}/runs`), getRun: (runId: string) => request(`/api/v1/runs/${runId}`), listReports: (companyId: string) => request(`/api/v1/companies/${companyId}/reports`), generateReport: (companyId: string) => request(`/api/v1/companies/${companyId}/reports/generate`, { method: "POST", }), getReport: (reportId: string) => request(`/api/v1/reports/${reportId}`), getReportMarkdown: (reportId: string) => fetch(`${API_BASE_URL}/api/v1/reports/${reportId}/markdown`, { headers: (() => { const h = new Headers(); const token = getAccessToken(); if (token) h.set("Authorization", `Bearer ${token}`); return h; })(), }).then((r) => r.text()), };