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,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 },
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }[]
|
||||
}
|
||||
Reference in New Issue
Block a user