Files
lux_fiscal/frontend/src/api/client.ts
T
lauadminandClaude Opus 5.5 b8f2fe6b6e Translate UI and user-facing messages to Ukrainian (#5)
- All frontend pages, labels, notices and errors; html lang=uk, uk-UA money format
- Brand "Assistant System" in the top bar and page title
- Backend error details returned to the UI (auth, orders, receipts,
  cash registers, Checkbox/CRM/NP errors) and CLI output
- Tests updated for the new messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:27:39 +03:00

126 lines
4.3 KiB
TypeScript

import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/api/tokenStore'
import type { ApiErrorBody, TokenPair } from '@/api/types'
const API_PREFIX = '/api/v1'
/** Эндпоинты, которые не должны получать Authorization и не должны запускать refresh. */
const PUBLIC_PATHS = new Set(['/auth/login', '/auth/refresh'])
export class ApiError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function readErrorMessage(response: Response): Promise<string> {
try {
const body = (await response.json()) as ApiErrorBody
if (typeof body.detail === 'string') return body.detail
if (Array.isArray(body.detail)) {
return body.detail.map((item) => item.msg).join('; ')
}
} catch {
// Тело не JSON или пустое — используем сообщение по статусу ниже.
}
if (response.status === 401) return 'Потрібно увійти в систему'
if (response.status === 403) return 'Недостатньо прав для цієї дії'
if (response.status >= 500) return 'Сервер тимчасово недоступний, спробуйте пізніше'
return `Помилка запиту (${response.status})`
}
// Параллельные 401 не должны порождать несколько запросов на refresh —
// все ждут один и тот же промис.
let refreshPromise: Promise<string> | null = null
async function refreshAccessToken(): Promise<string> {
if (refreshPromise) return refreshPromise
const refreshToken = getRefreshToken()
if (!refreshToken) {
throw new ApiError(401, 'Сесія закінчилася, увійдіть знову')
}
refreshPromise = (async () => {
try {
const response = await fetch(`${API_PREFIX}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
})
if (!response.ok) {
clearTokens()
throw new ApiError(response.status, await readErrorMessage(response))
}
const pair = (await response.json()) as TokenPair
setTokens(pair.access_token, pair.refresh_token)
return pair.access_token
} finally {
refreshPromise = null
}
})()
return refreshPromise
}
export interface ApiFetchOptions extends Omit<RequestInit, 'body'> {
body?: unknown
}
/**
* Обёртка над fetch: подставляет Authorization, при первом 401 на защищённом
* эндпоинте один раз молча обновляет access-токен и повторяет запрос.
*/
export async function apiFetch<T = void>(path: string, options: ApiFetchOptions = {}): Promise<T> {
const isPublic = PUBLIC_PATHS.has(path)
const accessToken = getAccessToken()
const headers = new Headers(options.headers)
headers.set('Content-Type', 'application/json')
if (accessToken && !isPublic) {
headers.set('Authorization', `Bearer ${accessToken}`)
}
const response = await fetch(`${API_PREFIX}${path}`, {
...options,
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
})
if (response.status === 401 && !isPublic) {
try {
const newAccessToken = await refreshAccessToken()
const retryHeaders = new Headers(options.headers)
retryHeaders.set('Content-Type', 'application/json')
retryHeaders.set('Authorization', `Bearer ${newAccessToken}`)
const retryResponse = await fetch(`${API_PREFIX}${path}`, {
...options,
headers: retryHeaders,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
})
if (!retryResponse.ok) {
throw new ApiError(retryResponse.status, await readErrorMessage(retryResponse))
}
if (retryResponse.status === 204) return undefined as T
return (await retryResponse.json()) as T
} catch (err) {
clearTokens()
throw err
}
}
if (!response.ok) {
throw new ApiError(response.status, await readErrorMessage(response))
}
if (response.status === 204) return undefined as T
return (await response.json()) as T
}