Files
ui/src/api/client.ts
T
Oleksandr Bezdieniezhnykh 085d7bf17e Merge API remote base URL config into dev
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 18:19:02 +03:00

119 lines
4.2 KiB
TypeScript

import { endpoints } from './endpoints'
let accessToken: string | null = null
/**
* Single source of truth for the in-memory bearer token.
*
* The bearer is intentionally held in module scope (NOT in localStorage /
* sessionStorage / cookies) per AC-02 / restriction O2 — the only way to
* survive a page reload is through the HttpOnly refresh cookie + the
* `refreshToken()` round-trip in `request()` below.
*
* Tests override this hook to seed a bearer without going through the full
* login flow. See `_docs/02_document/tests/test-data.md` ("Stubbed bearer /
* cookie in test helpers"). DO NOT delete this accessor as "dead code" —
* it is reflectively used by the test harness; the import looks dead at
* static-grep time but is not.
*/
export function setToken(token: string | null) {
accessToken = token
}
/** Read-side companion to {@link setToken}. */
export function getToken() {
return accessToken
}
/**
* Resolve the base URL prefix for every API request. Returns `''` (no
* prefix) on production builds where the SPA and the suite share the same
* nginx (E2); tests + alternative deployments override via the Vite env
* var `VITE_API_BASE_URL`. A trailing slash is stripped so a value of
* `http://host/` does not produce `http://host//api/...`.
*/
export function getApiBase(): string {
const raw = import.meta.env.VITE_API_BASE_URL
if (!raw) return ''
return raw.replace(/\/+$/, '')
}
export function authenticatedApiUrl(path: string): string {
const url = getApiBase() + path
if (!accessToken) return url
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}access_token=${encodeURIComponent(accessToken)}`
}
/**
* Indirection for the failed-refresh redirect. Default impl writes
* `'/login'` to `window.location.href` — the production behavior. Tests
* override via {@link setNavigateToLogin} to assert "redirect was invoked"
* without globally stubbing `window.location`. Must be reset by the test
* harness in teardown (see `_docs/02_document/tests/test-data.md`).
*/
let navigateToLoginImpl: () => void = () => {
window.location.href = '/login'
}
export function setNavigateToLogin(fn: () => void) {
navigateToLoginImpl = fn
}
async function handleResponse<T>(res: Response): Promise<T> {
if (res.status === 204) return undefined as T
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`${res.status}: ${text || res.statusText}`)
}
return res.json()
}
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers)
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`)
if (options.body && typeof options.body === 'string') headers.set('Content-Type', 'application/json')
const fullUrl = getApiBase() + url
let res = await fetch(fullUrl, { ...options, headers, credentials: 'include' })
if (res.status === 401 && accessToken) {
const refreshed = await refreshToken()
if (refreshed) {
headers.set('Authorization', `Bearer ${accessToken}`)
res = await fetch(fullUrl, { ...options, headers, credentials: 'include' })
} else {
setToken(null)
navigateToLoginImpl()
throw new Error('Session expired')
}
}
return handleResponse<T>(res)
}
async function refreshToken(): Promise<boolean> {
try {
const res = await fetch(getApiBase() + endpoints.admin.authRefresh(), { method: 'POST', credentials: 'include' })
if (!res.ok) return false
const data = await res.json()
setToken(data.token)
return true
} catch {
return false
}
}
export const api = {
get: <T>(url: string) => request<T>(url),
post: <T>(url: string, body?: unknown) =>
request<T>(url, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T>(url: string, body?: unknown) =>
request<T>(url, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
patch: <T>(url: string, body?: unknown) =>
request<T>(url, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
delete: <T>(url: string) => request<T>(url, { method: 'DELETE' }),
upload: <T>(url: string, formData: FormData) =>
request<T>(url, { method: 'POST', body: formData }),
}