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
@@ -0,0 +1,95 @@
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import * as authApi from '@/api/auth'
import { ApiError } from '@/api/client'
import { clearTokens, getRefreshToken, setTokens } from '@/api/tokenStore'
import type { User } from '@/api/types'
type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'
interface AuthContextValue {
status: AuthStatus
user: User | null
/** null, пока запрос не завершится (успехом или ошибкой). */
error: string | null
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
}
// oxlint-disable-next-line react/only-export-components -- контекст и провайдер экспортируются вместе намеренно
export const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
// Если refresh-токена нет, статус известен сразу же, без похода в сеть —
// вычисляем его в инициализаторе, а не синхронным setState внутри эффекта.
const [status, setStatus] = useState<AuthStatus>(() =>
getRefreshToken() ? 'loading' : 'unauthenticated',
)
const [user, setUser] = useState<User | null>(null)
const [error, setError] = useState<string | null>(null)
// При открытии приложения пытаемся восстановить сессию по refresh-токену
// из localStorage — иначе каждая перезагрузка страницы требовала бы входа.
useEffect(() => {
const existingRefreshToken = getRefreshToken()
if (!existingRefreshToken) return
let cancelled = false
void (async () => {
try {
const pair = await authApi.refresh(existingRefreshToken)
setTokens(pair.access_token, pair.refresh_token)
const me = await authApi.fetchMe()
if (cancelled) return
setUser(me)
setStatus('authenticated')
} catch {
if (cancelled) return
clearTokens()
setStatus('unauthenticated')
}
})()
return () => {
cancelled = true
}
}, [])
const login = useCallback(async (email: string, password: string) => {
setError(null)
try {
const result = await authApi.login(email, password)
setTokens(result.access_token, result.refresh_token)
setUser(result.user)
setStatus('authenticated')
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу'
setError(message)
throw err
}
}, [])
const logout = useCallback(async () => {
const refreshToken = getRefreshToken()
if (refreshToken) {
// Best-effort: даже если сервер недоступен, локально выходим всё равно.
try {
await authApi.logout(refreshToken)
} catch {
// Токен и так будет забыт локально ниже.
}
}
clearTokens()
setUser(null)
setStatus('unauthenticated')
}, [])
const value = useMemo<AuthContextValue>(
() => ({ status, user, error, login, logout }),
[status, user, error, login, logout],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}