Initial commit: backend scaffold, auth, frontend login
- FastAPI + SQLAlchemy async + Alembic + Postgres backend - Auth: JWT access + rotating refresh tokens, argon2, roles, audit log - React 19 + Vite frontend: login page, protected route, auth context - Docker Compose: postgres, redis, migrate, api, worker (placeholder), frontend/nginx Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user