mirror of
https://github.com/azaion/ui.git
synced 2026-06-24 16:31:11 +00:00
085d7bf17e
Co-authored-by: Cursor <cursoragent@cursor.com>
119 lines
4.2 KiB
TypeScript
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 }),
|
|
}
|