- 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>
126 lines
4.3 KiB
TypeScript
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
|
|
}
|