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
+34
View File
@@ -0,0 +1,34 @@
import { apiFetch } from '@/api/client'
import type { LoginResponse, TokenPair, User } from '@/api/types'
export function login(email: string, password: string): Promise<LoginResponse> {
return apiFetch<LoginResponse>('/auth/login', {
method: 'POST',
body: { email, password },
})
}
export function refresh(refreshToken: string): Promise<TokenPair> {
return apiFetch<TokenPair>('/auth/refresh', {
method: 'POST',
body: { refresh_token: refreshToken },
})
}
export function logout(refreshToken: string): Promise<void> {
return apiFetch<void>('/auth/logout', {
method: 'POST',
body: { refresh_token: refreshToken },
})
}
export function fetchMe(): Promise<User> {
return apiFetch<User>('/auth/me')
}
export function changePassword(currentPassword: string, newPassword: string): Promise<void> {
return apiFetch<void>('/auth/change-password', {
method: 'POST',
body: { current_password: currentPassword, new_password: newPassword },
})
}
+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
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Хранилище токенов вне React-дерева.
*
* `client.ts` должен уметь читать access-токен и молча обновлять его по 401
* без импорта React-контекста (иначе получится цикл api -> auth-context ->
* api). Поэтому токены живут в модульном синглтоне, а `AuthProvider`
* подписывается на его изменения через `subscribe`.
*
* access-токен — только в памяти (переживает не дольше вкладки).
* refresh-токен — в localStorage, иначе при обновлении страницы
* пользователя выкидывало бы из системы каждый раз.
*/
const REFRESH_TOKEN_KEY = 'lux_fiscal.refresh_token'
interface TokenState {
accessToken: string | null
refreshToken: string | null
}
let state: TokenState = {
accessToken: null,
refreshToken: readRefreshTokenFromStorage(),
}
type Listener = (state: TokenState) => void
const listeners = new Set<Listener>()
function readRefreshTokenFromStorage(): string | null {
try {
return localStorage.getItem(REFRESH_TOKEN_KEY)
} catch {
// Приватный режим браузера может запрещать доступ к localStorage.
return null
}
}
function writeRefreshTokenToStorage(token: string | null): void {
try {
if (token) {
localStorage.setItem(REFRESH_TOKEN_KEY, token)
} else {
localStorage.removeItem(REFRESH_TOKEN_KEY)
}
} catch {
// Не критично: без localStorage просто не переживём перезагрузку страницы.
}
}
function notify(): void {
for (const listener of listeners) listener(state)
}
export function getAccessToken(): string | null {
return state.accessToken
}
export function getRefreshToken(): string | null {
return state.refreshToken
}
export function setTokens(accessToken: string, refreshToken: string): void {
state = { accessToken, refreshToken }
writeRefreshTokenToStorage(refreshToken)
notify()
}
export function setAccessToken(accessToken: string): void {
state = { ...state, accessToken }
notify()
}
export function clearTokens(): void {
state = { accessToken: null, refreshToken: null }
writeRefreshTokenToStorage(null)
notify()
}
/** Вызывается при монтировании AuthProvider и при каждом изменении токенов. */
export function subscribeToTokens(listener: Listener): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Типы, зеркалящие Pydantic-схемы бэкенда (backend/app/schemas/auth.py,
* backend/app/db/models/user.py). Меняются синхронно с ними вручную —
* генерация клиента из OpenAPI запланирована на более поздний этап.
*/
// `erasableSyntaxOnly` в tsconfig запрещает TS-enum — используем union строк,
// он полностью совпадает по значениям с UserRole на бэкенде.
export type UserRole = 'admin' | 'cashier' | 'viewer'
export interface User {
id: string
email: string
full_name: string
role: UserRole
is_active: boolean
}
export interface TokenPair {
access_token: string
refresh_token: string
token_type: string
expires_in: number
}
export interface LoginResponse extends TokenPair {
user: User
}
export interface ApiErrorBody {
detail?: string | { msg: string }[]
}