import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig, } from 'axios'; import type { HealthResponse, InspectionCreateResponse, InspectionStatusResponse, SyncInspectionResponse, InspectionListResponse, InspectionStatus, } from '@arac-hasar/types'; import { isJwtExpired } from './jwt'; import { getSelectedModelId } from './models'; const API_URL = process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, '') ?? 'http://localhost:8000'; export const API_BASE_URL = API_URL; export const TOKEN_STORAGE_KEY = 'arac_hasar_access_token'; export const REFRESH_STORAGE_KEY = 'arac_hasar_refresh_token'; export const TOKEN_COOKIE = 'access_token'; // Cross-tab coordination for refresh-token rotation. Each tab attempts to // acquire a short-lived lock; if another tab is already refreshing, the // follower waits for the new access token to land in localStorage instead // of hitting /auth/refresh in parallel (refresh tokens are single-use, so // the loser of the race would otherwise invalidate the winner). const REFRESH_LOCK_KEY = 'arac_hasar_refresh_lock'; const REFRESH_LOCK_TTL_MS = 10_000; const REFRESH_FOLLOWER_TIMEOUT_MS = 8_000; /* ---------- token storage helpers ---------- */ function isBrowser(): boolean { return typeof window !== 'undefined'; } export function setStoredTokens(access: string, refresh?: string) { if (!isBrowser()) return; localStorage.setItem(TOKEN_STORAGE_KEY, access); if (refresh) localStorage.setItem(REFRESH_STORAGE_KEY, refresh); // Cookie for SSR middleware (decode-only). 7 days; renew on each login. const maxAge = 60 * 60 * 24 * 7; document.cookie = `${TOKEN_COOKIE}=${encodeURIComponent( access, )}; path=/; max-age=${maxAge}; samesite=lax`; } export function clearStoredTokens() { if (!isBrowser()) return; localStorage.removeItem(TOKEN_STORAGE_KEY); localStorage.removeItem(REFRESH_STORAGE_KEY); document.cookie = `${TOKEN_COOKIE}=; path=/; max-age=0; samesite=lax`; } export function getStoredAccessToken(): string | null { if (!isBrowser()) return null; return localStorage.getItem(TOKEN_STORAGE_KEY); } export function getStoredRefreshToken(): string | null { if (!isBrowser()) return null; return localStorage.getItem(REFRESH_STORAGE_KEY); } /* ---------- axios instance with interceptors ---------- */ let _client: AxiosInstance | null = null; let _refreshPromise: Promise | null = null; let _onUnauthorized: (() => void) | null = null; export function setUnauthorizedHandler(handler: (() => void) | null) { _onUnauthorized = handler; } function buildClient(): AxiosInstance { const instance = axios.create({ baseURL: API_URL, timeout: 60_000, withCredentials: false, }); instance.interceptors.request.use((config: InternalAxiosRequestConfig) => { const token = getStoredAccessToken(); if (token) { config.headers.set('Authorization', `Bearer ${token}`); } return config; }); instance.interceptors.response.use( (r) => r, async (error: AxiosError) => { const status = error.response?.status; const original = error.config as | (InternalAxiosRequestConfig & { _retry?: boolean }) | undefined; if (status === 401 && original && !original._retry) { original._retry = true; const refreshed = await runRefresh(); if (refreshed) { original.headers?.set?.('Authorization', `Bearer ${refreshed}`); return instance.request(original); } clearStoredTokens(); _onUnauthorized?.(); } return Promise.reject(error); }, ); return instance; } export function client(): AxiosInstance { if (_client) return _client; _client = buildClient(); return _client; } /** Try to claim the cross-tab refresh lock. Returns true if we own it. */ function tryAcquireRefreshLock(): boolean { if (!isBrowser()) return true; try { const now = Date.now(); const raw = localStorage.getItem(REFRESH_LOCK_KEY); if (raw) { const ts = parseInt(raw, 10); // Stale lock (tab crashed) → steal it. if (Number.isFinite(ts) && now - ts < REFRESH_LOCK_TTL_MS) { return false; } } localStorage.setItem(REFRESH_LOCK_KEY, String(now)); // Re-read to confirm we won the race (best-effort; localStorage writes // are synchronous within a tab, so the second writer in another tab // overwrites us — but we'll detect that when our refresh response either // succeeds or fails, and in the failure path we fall back to waiting). return localStorage.getItem(REFRESH_LOCK_KEY) === String(now); } catch { return true; // localStorage unavailable: behave as single-tab. } } function releaseRefreshLock() { if (!isBrowser()) return; try { localStorage.removeItem(REFRESH_LOCK_KEY); } catch { /* ignore */ } } /** * Wait for another tab to publish a fresh access token. Resolves with the * new token, or `null` if the leader did not publish within the timeout * (caller should then treat the session as dead). */ function waitForLeaderRefresh(previousToken: string | null): Promise { if (!isBrowser()) return Promise.resolve(null); return new Promise((resolve) => { const start = Date.now(); let done = false; const finish = (token: string | null) => { if (done) return; done = true; window.removeEventListener('storage', onStorage); clearInterval(poll); resolve(token); }; const onStorage = (e: StorageEvent) => { if (e.key === TOKEN_STORAGE_KEY && e.newValue && e.newValue !== previousToken) { finish(e.newValue); } }; window.addEventListener('storage', onStorage); // Fallback poll for same-tab edge cases (storage events do not fire in // the writing tab) and for browsers that batch them. const poll = setInterval(() => { const current = getStoredAccessToken(); if (current && current !== previousToken) { finish(current); return; } if (Date.now() - start >= REFRESH_FOLLOWER_TIMEOUT_MS) { finish(null); } }, 200); }); } async function runRefresh(): Promise { // In-tab dedup: while one request is refreshing, others await the same // promise instead of firing a second /auth/refresh. if (_refreshPromise) return _refreshPromise; const refresh = getStoredRefreshToken(); if (!refresh) return null; const previousAccess = getStoredAccessToken(); const isLeader = tryAcquireRefreshLock(); _refreshPromise = (async () => { try { if (!isLeader) { // Follower: wait for the leader tab to write the new token. const token = await waitForLeaderRefresh(previousAccess); return token; } const res = await axios.post<{ access_token: string; refresh_token?: string; }>(`${API_URL}/auth/refresh`, { refresh_token: refresh }); const { access_token, refresh_token } = res.data; setStoredTokens(access_token, refresh_token ?? refresh); return access_token; } catch { return null; } finally { if (isLeader) releaseRefreshLock(); _refreshPromise = null; } })(); return _refreshPromise; } /* ---------- auth ---------- */ export interface User { id: string; email: string; full_name?: string; role?: 'admin' | 'user' | string; created_at?: string; is_active?: boolean; } export interface AuthTokens { access_token: string; refresh_token?: string; } export interface LoginResponse extends AuthTokens { user?: User; } export interface RegisterResponse extends AuthTokens { user: User; } export const auth = { async login(email: string, password: string): Promise { const res = await client().post('/auth/login', { email, password, }); return res.data; }, async register( email: string, password: string, full_name: string, ): Promise { const res = await client().post('/auth/register', { email, password, full_name, }); return res.data; }, async me(): Promise { const res = await client().get('/auth/me'); return res.data; }, async refresh(): Promise { return runRefresh(); }, async changePassword(current: string, next: string): Promise { await client().post('/auth/change-password', { current_password: current, new_password: next, }); }, async updateProfile(input: { full_name?: string }): Promise { const res = await client().patch('/auth/me', input); return res.data; }, hasValidSession(): boolean { const t = getStoredAccessToken(); if (!t) return false; return !isJwtExpired(t, 5); }, }; /* ---------- inspections ---------- */ export interface CreateInspectionOptions { /** sync = inline (max 3 files), async = queued */ mode?: 'sync' | 'async'; apiKey?: string; signal?: AbortSignal; onUploadProgress?: (loaded: number, total: number) => void; } export interface ListInspectionsOptions { page?: number; pageSize?: number; status?: InspectionStatus; dateFrom?: string; // ISO dateTo?: string; // ISO query?: string; apiKey?: string; signal?: AbortSignal; } export const inspections = { async create( files: File[], opts: CreateInspectionOptions = {}, ): Promise { const { mode = 'async', apiKey, signal, onUploadProgress } = opts; if (!files || files.length === 0) { throw new Error('NO_FILES'); } const form = new FormData(); // IMPORTANT: // - /api/v1/inspect expects field name 'files' (List[UploadFile]) // - /api/v1/inspect/sync expects field name 'file' (single UploadFile) // Always use the multi-file endpoint so the same code path supports 1..N // images. We pass ?mode=sync|async on the query string. files.forEach((f) => form.append('files', f, f.name)); // Append the user-selected model (header dropdown) so the backend can // route the request to either the pre-trained weights or the // fine-tuned custom checkpoint. Falls back to the documented default // when the header has not initialized yet (SSR / first paint). const modelId = getSelectedModelId(); const params = new URLSearchParams(); params.set('mode', mode === 'sync' ? 'sync' : 'async'); if (modelId) params.set('model', modelId); const path = `/api/v1/inspect?${params.toString()}`; // CRITICAL: do NOT set Content-Type manually. axios/the browser must add // it automatically so that the multipart boundary token is included in // the header. Setting `Content-Type: multipart/form-data` here strips the // boundary and the backend rejects the upload as malformed. // CPU-only HF Spaces: tek inference 9-36s; N foto * 45s + upload süresi // global 60s timeout'u aşar → "Bağlantı zaman aşımı" yanlış göstergesi. // Per-call uzatma: en az 5dk, foto başına 60s. Sync mode'da kritik. const dynamicTimeout = mode === 'sync' ? Math.max(300_000, files.length * 60_000) : 120_000; const res = await client().post< InspectionCreateResponse | SyncInspectionResponse >(path, form, { headers: apiKey ? { 'X-API-Key': apiKey } : undefined, signal, timeout: dynamicTimeout, onUploadProgress: (evt) => { if (onUploadProgress && evt.total) { onUploadProgress(evt.loaded, evt.total); } }, }); return res.data; }, async get( inspectionId: string, opts: { apiKey?: string; signal?: AbortSignal } = {}, ): Promise { const res = await client().get( `/api/v1/inspect/${encodeURIComponent(inspectionId)}`, { headers: opts.apiKey ? { 'X-API-Key': opts.apiKey } : undefined, signal: opts.signal, }, ); return res.data; }, async list(opts: ListInspectionsOptions = {}): Promise { const { page = 1, pageSize = 20, status, dateFrom, dateTo, query, apiKey, signal, } = opts; const res = await client().get(`/api/v1/inspect`, { params: { page, page_size: pageSize, status, date_from: dateFrom, date_to: dateTo, q: query || undefined, }, headers: apiKey ? { 'X-API-Key': apiKey } : undefined, signal, }); return res.data; }, async delete(inspectionId: string): Promise { await client().delete(`/api/v1/inspect/${encodeURIComponent(inspectionId)}`); }, visualization( inspectionId: string, type: 'annotated' | 'parts' | 'damages', ): string { return `${API_URL}/api/v1/inspect/${encodeURIComponent(inspectionId)}/visualization/${type}`; }, }; /* ---------- api keys ---------- */ export interface ApiKey { id: string; name: string; prefix: string; created_at: string; last_used_at?: string | null; revoked: boolean; } export interface ApiKeyCreateResponse { key: ApiKey; /** Plaintext key returned ONCE on creation. */ secret: string; } export const apiKeys = { async list(): Promise { const res = await client().get('/auth/api-keys'); return res.data; }, async create(name: string): Promise { const res = await client().post('/auth/api-keys', { name, }); return res.data; }, async revoke(id: string): Promise { await client().delete(`/auth/api-keys/${encodeURIComponent(id)}`); }, }; /* ---------- admin: users ---------- */ export const adminUsers = { async list(): Promise { const res = await client().get('/admin/users'); return res.data; }, async setRole(userId: string, role: 'admin' | 'user'): Promise { const res = await client().patch( `/admin/users/${encodeURIComponent(userId)}/role`, { role }, ); return res.data; }, async setActive(userId: string, is_active: boolean): Promise { const res = await client().patch( `/admin/users/${encodeURIComponent(userId)}/active`, { is_active }, ); return res.data; }, }; /* ---------- misc ---------- */ export async function getHealth(): Promise { const res = await client().get('/health'); return res.data; } /* ---------- backward-compat exports (existing pages still use these) ---------- */ export async function createInspection( files: File[], opts: CreateInspectionOptions = {}, ): Promise { return inspections.create(files, opts); } export async function getInspectionStatus( inspectionId: string, opts: { apiKey?: string; signal?: AbortSignal } = {}, ): Promise { return inspections.get(inspectionId, opts); } export async function listInspections( opts: ListInspectionsOptions = {}, ): Promise { return inspections.list(opts); } export function inspectionVisualizationUrl( inspectionId: string, type: 'annotated' | 'parts' | 'damages', ): string { return inspections.visualization(inspectionId, type); } export function isSyncResponse( r: InspectionCreateResponse | SyncInspectionResponse, ): r is SyncInspectionResponse { return 'result' in r && 'processed_at' in r; } /* ---------- error extraction ---------- */ /** * Classify an axios/fetch error into a stable kind plus the first available * human-readable detail string. Callers translate the kind via next-intl; * `detail` (when present) is the raw server message (e.g. FastAPI HTTPException * detail) which is usually already localized server-side. */ export interface ApiErrorInfo { kind: | 'network' | 'cancelled' | 'timeout' | 'badRequest' | 'unauthorized' | 'forbidden' | 'notFound' | 'tooLarge' | 'unsupportedMedia' | 'validation' | 'rateLimited' | 'server' | 'unknown'; status?: number; detail?: string; /** Field-level errors from FastAPI 422 (RequestValidationError). */ fieldErrors?: Array<{ field: string; message: string }>; } export function classifyApiError(err: unknown): ApiErrorInfo { if (axios.isCancel(err)) return { kind: 'cancelled' }; if (!axios.isAxiosError(err)) return { kind: 'unknown' }; if (err.code === 'ECONNABORTED' || err.code === 'ETIMEDOUT') { return { kind: 'timeout' }; } if (!err.response) return { kind: 'network' }; const { status, data } = err.response; // FastAPI: { detail: string } or { detail: [{ loc, msg, type }, ...] } let detail: string | undefined; let fieldErrors: Array<{ field: string; message: string }> | undefined; if (data && typeof data === 'object') { const d = (data as { detail?: unknown }).detail; if (typeof d === 'string') { detail = d; } else if (Array.isArray(d)) { fieldErrors = d .map((e) => { if (!e || typeof e !== 'object') return null; const loc = (e as { loc?: unknown[] }).loc; const msg = (e as { msg?: unknown }).msg; const field = Array.isArray(loc) ? loc.filter((p) => p !== 'body' && p !== 'query').join('.') : ''; return typeof msg === 'string' ? { field: field || '_', message: msg } : null; }) .filter((x): x is { field: string; message: string } => x !== null); detail = fieldErrors[0]?.message; } } const base: ApiErrorInfo = { kind: 'unknown', status, detail, fieldErrors }; if (status === 400) return { ...base, kind: 'badRequest' }; if (status === 401) return { ...base, kind: 'unauthorized' }; if (status === 403) return { ...base, kind: 'forbidden' }; if (status === 404) return { ...base, kind: 'notFound' }; if (status === 413) return { ...base, kind: 'tooLarge' }; if (status === 415) return { ...base, kind: 'unsupportedMedia' }; if (status === 422) return { ...base, kind: 'validation' }; if (status === 429) return { ...base, kind: 'rateLimited' }; if (status && status >= 500) return { ...base, kind: 'server' }; return base; }