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