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:
2026-09-22 15:07:15 +03:00
co-authored by Claude Sonnet 5
commit 2664cb8213
71 changed files with 4999 additions and 0 deletions
+125
View File
@@ -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
}