/** * Shared API client with cookie-based authentication. * Tokens are managed via HttpOnly cookies set by the server — * the frontend never reads or stores JWT tokens. */ const API_BASE = import.meta.env.VITE_API_BASE || '' export interface ApiResult { success: boolean data?: T error?: string } export class ApiError extends Error { constructor( public status: number, public code: string, message: string, ) { super(message) this.name = 'ApiError' } } let refreshPromise: Promise | null = null /** Cached user info from /api/auth/me */ let cachedUser: { id: number; username: string; role: string } | null = null export function getCachedUser() { return cachedUser } export function clearCachedUser() { cachedUser = null } async function tryRefresh(): Promise { if (refreshPromise) return refreshPromise refreshPromise = (async () => { try { const response = await fetch(`${API_BASE}/api/auth/refresh`, { method: 'POST', credentials: 'same-origin', }) if (!response.ok) return false const result = await response.json() if (!result.success) return false // Update cached user from refresh response if (result.data?.user) { cachedUser = result.data.user } return true } catch { return false } finally { refreshPromise = null } })() return refreshPromise } async function request( path: string, options: RequestInit = {}, ): Promise { const headers = new Headers(options.headers || {}) if (options.body && typeof options.body === 'string') { headers.set('Content-Type', 'application/json') } const response = await fetch(`${API_BASE}${path}`, { ...options, headers, credentials: 'same-origin', }) // Handle 401 - try refresh before giving up if (response.status === 401) { const refreshed = await tryRefresh() if (refreshed) { const retryResponse = await fetch(`${API_BASE}${path}`, { ...options, headers, credentials: 'same-origin', }) if (retryResponse.status === 401) { clearCachedUser() window.location.href = '/login' throw new ApiError(401, 'UNAUTHORIZED', 'Session expired') } const retryContentType = retryResponse.headers.get('content-type') if (!retryContentType || !retryContentType.includes('application/json')) { throw new ApiError(retryResponse.status, 'NON_JSON_RESPONSE', `Server returned ${retryResponse.status}`) } const retryResult: ApiResult = await retryResponse.json() if (!retryResult.success) { throw new ApiError(retryResponse.status, 'API_ERROR', retryResult.error || 'Unknown error') } return retryResult.data as T } clearCachedUser() window.location.href = '/login' throw new ApiError(401, 'UNAUTHORIZED', 'Session expired') } // Handle 403 - insufficient permissions if (response.status === 403) { throw new ApiError(403, 'FORBIDDEN', 'Insufficient permissions') } // Handle non-JSON responses (502, 503, etc.) const contentType = response.headers.get('content-type') if (!contentType || !contentType.includes('application/json')) { throw new ApiError(response.status, 'NON_JSON_RESPONSE', `Server returned ${response.status}`) } const result: ApiResult = await response.json() if (!result.success) { throw new ApiError(response.status, 'API_ERROR', result.error || 'Unknown error') } return result.data as T } export const api = { get(path: string): Promise { return request(path) }, post(path: string, body?: unknown): Promise { return request(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined, }) }, put(path: string, body?: unknown): Promise { return request(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined, }) }, delete(path: string): Promise { return request(path, { method: 'DELETE' }) }, /** Login — server sets HttpOnly cookies, we only get user info back */ async login(username: string, password: string): Promise<{ user: { id: number; username: string; role: string } }> { const response = await fetch(`${API_BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ username, password }), }) const result = await response.json() if (!result.success) { throw new ApiError(response.status, 'LOGIN_FAILED', result.error || 'Login failed') } cachedUser = result.data.user return result.data }, /** Logout — server clears cookies */ async logout(): Promise { try { await fetch(`${API_BASE}/api/auth/logout`, { method: 'POST', credentials: 'same-origin', }) } catch { // Ignore errors during logout } clearCachedUser() }, /** Check current auth status via /api/auth/me */ async me(): Promise<{ user: { id: number; username: string; role: string }; expires_at: string }> { const result = await request<{ user: { id: number; username: string; role: string }; expires_at: string }>('/api/auth/me') cachedUser = (result as { user: { id: number; username: string; role: string } }).user return result }, }