2026-01-10 18:38:15 +03:00
|
|
|
|
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
|
2026-01-01 18:21:18 +03:00
|
|
|
|
|
|
|
|
|
|
const AuthContext = createContext(null)
|
|
|
|
|
|
|
|
|
|
|
|
const TOKEN_KEY = 'access_token'
|
|
|
|
|
|
const REFRESH_TOKEN_KEY = 'refresh_token'
|
|
|
|
|
|
const USER_KEY = 'user'
|
|
|
|
|
|
|
|
|
|
|
|
export function AuthProvider({ children }) {
|
|
|
|
|
|
const [user, setUser] = useState(null)
|
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
|
const [error, setError] = useState(null)
|
2026-01-10 18:38:15 +03:00
|
|
|
|
|
|
|
|
|
|
// Ref для синхронизации параллельных refresh-запросов
|
|
|
|
|
|
const refreshPromiseRef = useRef(null)
|
2026-01-01 18:21:18 +03:00
|
|
|
|
|
2026-01-02 16:15:42 +03:00
|
|
|
|
const logout = useCallback(async () => {
|
|
|
|
|
|
const token = localStorage.getItem(TOKEN_KEY)
|
|
|
|
|
|
|
|
|
|
|
|
if (token) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fetch('/api/auth/logout', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Authorization': `Bearer ${token}`,
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Logout error:', err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
localStorage.removeItem(TOKEN_KEY)
|
|
|
|
|
|
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
|
|
|
|
|
localStorage.removeItem(USER_KEY)
|
|
|
|
|
|
setUser(null)
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2026-01-10 18:38:15 +03:00
|
|
|
|
// Внутренняя функция для выполнения refresh
|
|
|
|
|
|
const doRefreshToken = useCallback(async () => {
|
2026-01-02 16:15:42 +03:00
|
|
|
|
const refresh = localStorage.getItem(REFRESH_TOKEN_KEY)
|
|
|
|
|
|
|
|
|
|
|
|
if (!refresh) {
|
2026-01-12 17:05:19 +03:00
|
|
|
|
console.warn('[Auth] No refresh token in localStorage')
|
2026-01-02 16:19:54 +03:00
|
|
|
|
return { success: false, isNetworkError: false }
|
2026-01-02 16:15:42 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 17:05:19 +03:00
|
|
|
|
console.log('[Auth] Attempting refresh with token:', refresh.substring(0, 10) + '...')
|
|
|
|
|
|
|
2026-01-02 16:15:42 +03:00
|
|
|
|
try {
|
2026-01-02 16:19:54 +03:00
|
|
|
|
const controller = new AbortController()
|
2026-01-12 17:05:19 +03:00
|
|
|
|
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout (increased)
|
2026-01-02 16:19:54 +03:00
|
|
|
|
|
2026-01-02 16:15:42 +03:00
|
|
|
|
const response = await fetch('/api/auth/refresh', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
2026-01-02 16:19:54 +03:00
|
|
|
|
body: JSON.stringify({ refresh_token: refresh }),
|
|
|
|
|
|
signal: controller.signal
|
2026-01-02 16:15:42 +03:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-02 16:19:54 +03:00
|
|
|
|
clearTimeout(timeoutId)
|
|
|
|
|
|
|
2026-01-02 16:15:42 +03:00
|
|
|
|
if (!response.ok) {
|
2026-01-12 17:05:19 +03:00
|
|
|
|
// Логируем тело ответа для диагностики
|
|
|
|
|
|
let errorBody = ''
|
|
|
|
|
|
try {
|
|
|
|
|
|
errorBody = await response.text()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
errorBody = 'Could not read error body'
|
|
|
|
|
|
}
|
|
|
|
|
|
console.error('[Auth] Refresh failed:', response.status, errorBody)
|
|
|
|
|
|
|
2026-01-02 16:19:54 +03:00
|
|
|
|
// 401 means invalid token (real auth error)
|
|
|
|
|
|
// Other errors might be temporary (503, 502, etc.)
|
|
|
|
|
|
const isAuthError = response.status === 401
|
|
|
|
|
|
return { success: false, isNetworkError: !isAuthError }
|
2026-01-02 16:15:42 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
|
2026-01-12 17:05:19 +03:00
|
|
|
|
// Проверяем что токены действительно пришли
|
|
|
|
|
|
if (!data.access_token || !data.refresh_token) {
|
|
|
|
|
|
console.error('[Auth] Refresh response missing tokens:', Object.keys(data))
|
|
|
|
|
|
return { success: false, isNetworkError: false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[Auth] Refresh successful, saving new tokens')
|
2026-01-02 16:15:42 +03:00
|
|
|
|
localStorage.setItem(TOKEN_KEY, data.access_token)
|
|
|
|
|
|
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token)
|
|
|
|
|
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
|
|
|
|
|
|
setUser(data.user)
|
|
|
|
|
|
|
2026-01-02 16:19:54 +03:00
|
|
|
|
return { success: true, isNetworkError: false }
|
2026-01-02 16:15:42 +03:00
|
|
|
|
} catch (err) {
|
2026-01-12 17:05:19 +03:00
|
|
|
|
console.error('[Auth] Refresh error:', err.name, err.message)
|
2026-01-02 16:19:54 +03:00
|
|
|
|
// Network errors should be treated as temporary
|
|
|
|
|
|
if (err.name === 'AbortError' ||
|
|
|
|
|
|
(err.name === 'TypeError' && (err.message.includes('fetch') || err.message.includes('Failed to fetch')))) {
|
2026-01-12 17:05:19 +03:00
|
|
|
|
console.warn('[Auth] Refresh token network error, keeping session')
|
2026-01-02 16:19:54 +03:00
|
|
|
|
return { success: false, isNetworkError: true }
|
|
|
|
|
|
}
|
|
|
|
|
|
// Other errors might be auth related
|
|
|
|
|
|
return { success: false, isNetworkError: false }
|
2026-01-02 16:15:42 +03:00
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2026-01-10 18:38:15 +03:00
|
|
|
|
// Синхронизированная функция refresh - предотвращает race condition
|
|
|
|
|
|
// Если refresh уже выполняется, все вызовы ждут его завершения
|
|
|
|
|
|
const refreshToken = useCallback(async () => {
|
|
|
|
|
|
// Если refresh уже выполняется, ждём его завершения
|
|
|
|
|
|
if (refreshPromiseRef.current) {
|
|
|
|
|
|
console.log('[Auth] Refresh already in progress, waiting...')
|
|
|
|
|
|
return refreshPromiseRef.current
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Создаём promise для refresh и сохраняем его
|
|
|
|
|
|
console.log('[Auth] Starting token refresh...')
|
|
|
|
|
|
refreshPromiseRef.current = doRefreshToken().finally(() => {
|
|
|
|
|
|
// Очищаем ref после завершения (успешного или нет)
|
|
|
|
|
|
refreshPromiseRef.current = null
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return refreshPromiseRef.current
|
|
|
|
|
|
}, [doRefreshToken])
|
|
|
|
|
|
|
2026-01-01 18:21:18 +03:00
|
|
|
|
// Initialize from localStorage
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const initAuth = async () => {
|
|
|
|
|
|
const token = localStorage.getItem(TOKEN_KEY)
|
|
|
|
|
|
const savedUser = localStorage.getItem(USER_KEY)
|
|
|
|
|
|
|
2026-01-02 16:19:54 +03:00
|
|
|
|
console.log('[Auth] Initializing auth, token exists:', !!token, 'user exists:', !!savedUser)
|
|
|
|
|
|
|
2026-01-01 18:21:18 +03:00
|
|
|
|
if (token && savedUser) {
|
|
|
|
|
|
try {
|
2026-01-02 16:19:54 +03:00
|
|
|
|
const parsedUser = JSON.parse(savedUser)
|
|
|
|
|
|
setUser(parsedUser) // Set user immediately from localStorage
|
|
|
|
|
|
console.log('[Auth] User restored from localStorage:', parsedUser.email)
|
|
|
|
|
|
|
2026-01-02 16:15:42 +03:00
|
|
|
|
// Verify token is still valid with timeout
|
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
|
const timeoutId = setTimeout(() => controller.abort(), 5000) // 5 second timeout
|
|
|
|
|
|
|
2026-01-01 18:21:18 +03:00
|
|
|
|
const response = await fetch('/api/auth/me', {
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Authorization': `Bearer ${token}`,
|
|
|
|
|
|
'Content-Type': 'application/json'
|
2026-01-02 16:15:42 +03:00
|
|
|
|
},
|
|
|
|
|
|
signal: controller.signal
|
2026-01-01 18:21:18 +03:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-02 16:15:42 +03:00
|
|
|
|
clearTimeout(timeoutId)
|
|
|
|
|
|
|
2026-01-01 18:21:18 +03:00
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
setUser(data.user)
|
|
|
|
|
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
|
2026-01-02 16:19:54 +03:00
|
|
|
|
console.log('[Auth] Token verified successfully')
|
2026-01-01 18:21:18 +03:00
|
|
|
|
} else if (response.status === 401) {
|
|
|
|
|
|
// Try to refresh token
|
2026-01-02 16:19:54 +03:00
|
|
|
|
console.log('[Auth] Access token expired, attempting refresh...')
|
|
|
|
|
|
const result = await refreshToken()
|
|
|
|
|
|
if (!result.success && !result.isNetworkError) {
|
|
|
|
|
|
// Only logout on real auth errors, not network errors
|
|
|
|
|
|
console.warn('[Auth] Refresh failed with auth error, logging out')
|
2026-01-01 18:21:18 +03:00
|
|
|
|
logout()
|
2026-01-02 16:19:54 +03:00
|
|
|
|
} else if (!result.success) {
|
|
|
|
|
|
// Network error - keep session, backend might be starting up
|
|
|
|
|
|
console.warn('[Auth] Token refresh failed due to network error, keeping session. User remains logged in.')
|
|
|
|
|
|
// User is already set from localStorage above, so they stay logged in
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log('[Auth] Token refreshed successfully')
|
2026-01-01 18:21:18 +03:00
|
|
|
|
}
|
2026-01-02 16:15:42 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
// For other errors (like 503, 502, network errors), don't clear auth
|
|
|
|
|
|
// Just log the error and keep the user logged in
|
2026-01-02 16:19:54 +03:00
|
|
|
|
console.warn('[Auth] Auth check failed with status:', response.status, 'but keeping session. User remains logged in.')
|
|
|
|
|
|
// User is already set from localStorage above, so they stay logged in
|
2026-01-01 18:21:18 +03:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
2026-01-02 16:15:42 +03:00
|
|
|
|
// Network errors (e.g., backend not ready) should not clear auth
|
|
|
|
|
|
// Only clear if it's a real auth error
|
|
|
|
|
|
if (err.name === 'AbortError') {
|
|
|
|
|
|
// Timeout - backend might be starting up, keep auth state
|
2026-01-02 16:19:54 +03:00
|
|
|
|
console.warn('[Auth] Auth check timeout, backend might be starting up. Keeping session. User remains logged in.')
|
|
|
|
|
|
// User is already set from localStorage above, so they stay logged in
|
2026-01-02 16:15:42 +03:00
|
|
|
|
} else if (err.name === 'TypeError' && (err.message.includes('fetch') || err.message.includes('Failed to fetch'))) {
|
|
|
|
|
|
// Network error - backend might be starting up, keep auth state
|
2026-01-02 16:19:54 +03:00
|
|
|
|
console.warn('[Auth] Network error during auth check, keeping session:', err.message, 'User remains logged in.')
|
|
|
|
|
|
// User is already set from localStorage above, so they stay logged in
|
2026-01-02 16:15:42 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
// Other errors - might be auth related
|
2026-01-02 16:19:54 +03:00
|
|
|
|
console.error('[Auth] Auth init error:', err)
|
2026-01-02 16:15:42 +03:00
|
|
|
|
// Don't automatically logout on unknown errors
|
2026-01-02 16:19:54 +03:00
|
|
|
|
// User is already set from localStorage above, so they stay logged in
|
2026-01-02 16:15:42 +03:00
|
|
|
|
}
|
2026-01-01 18:21:18 +03:00
|
|
|
|
}
|
2026-01-02 16:19:54 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
console.log('[Auth] No saved auth data found')
|
2026-01-01 18:21:18 +03:00
|
|
|
|
}
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
initAuth()
|
2026-01-02 16:15:42 +03:00
|
|
|
|
}, [refreshToken, logout])
|
2026-01-01 18:21:18 +03:00
|
|
|
|
|
|
|
|
|
|
const login = useCallback(async (email, password) => {
|
|
|
|
|
|
setError(null)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/auth/login', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({ email, password })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error(data.error || 'Ошибка входа')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
localStorage.setItem(TOKEN_KEY, data.access_token)
|
|
|
|
|
|
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token)
|
|
|
|
|
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
|
|
|
|
|
|
setUser(data.user)
|
|
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err.message)
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
const register = useCallback(async (email, password, name) => {
|
|
|
|
|
|
setError(null)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/auth/register', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({ email, password, name: name || undefined })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error(data.error || 'Ошибка регистрации')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
localStorage.setItem(TOKEN_KEY, data.access_token)
|
|
|
|
|
|
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token)
|
|
|
|
|
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
|
|
|
|
|
|
setUser(data.user)
|
|
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err.message)
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
const getToken = useCallback(() => {
|
|
|
|
|
|
return localStorage.getItem(TOKEN_KEY)
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch wrapper that handles auth
|
|
|
|
|
|
const authFetch = useCallback(async (url, options = {}) => {
|
|
|
|
|
|
const token = localStorage.getItem(TOKEN_KEY)
|
|
|
|
|
|
|
2026-01-11 21:12:26 +03:00
|
|
|
|
// Не устанавливаем Content-Type для FormData - браузер сделает это автоматически
|
|
|
|
|
|
const isFormData = options.body instanceof FormData
|
|
|
|
|
|
const headers = {}
|
|
|
|
|
|
|
|
|
|
|
|
if (!isFormData && !options.headers?.['Content-Type']) {
|
|
|
|
|
|
headers['Content-Type'] = 'application/json'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Добавляем пользовательские заголовки
|
|
|
|
|
|
if (options.headers) {
|
|
|
|
|
|
Object.assign(headers, options.headers)
|
2026-01-01 18:21:18 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (token) {
|
|
|
|
|
|
headers['Authorization'] = `Bearer ${token}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-02 16:15:42 +03:00
|
|
|
|
try {
|
|
|
|
|
|
let response = await fetch(url, { ...options, headers })
|
|
|
|
|
|
|
|
|
|
|
|
// If 401, try to refresh token and retry
|
|
|
|
|
|
if (response.status === 401) {
|
2026-01-12 17:05:19 +03:00
|
|
|
|
console.log('[Auth] Got 401 for', url, '- attempting token refresh')
|
2026-01-02 16:19:54 +03:00
|
|
|
|
const result = await refreshToken()
|
|
|
|
|
|
if (result.success) {
|
2026-01-12 17:05:19 +03:00
|
|
|
|
console.log('[Auth] Token refreshed, retrying request to', url)
|
2026-01-02 16:15:42 +03:00
|
|
|
|
const newToken = localStorage.getItem(TOKEN_KEY)
|
|
|
|
|
|
headers['Authorization'] = `Bearer ${newToken}`
|
|
|
|
|
|
response = await fetch(url, { ...options, headers })
|
2026-01-12 17:05:19 +03:00
|
|
|
|
console.log('[Auth] Retry response status:', response.status)
|
2026-01-02 16:19:54 +03:00
|
|
|
|
} else if (!result.isNetworkError) {
|
|
|
|
|
|
// Only logout if refresh failed due to auth error (not network error)
|
2026-01-12 17:05:19 +03:00
|
|
|
|
console.warn('[Auth] Refresh failed with auth error, logging out')
|
2026-01-02 16:15:42 +03:00
|
|
|
|
logout()
|
2026-01-12 17:05:19 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
console.warn('[Auth] Refresh failed with network error, keeping session but request failed')
|
2026-01-02 16:15:42 +03:00
|
|
|
|
}
|
2026-01-02 16:19:54 +03:00
|
|
|
|
// If network error, don't logout - let the caller handle the 401
|
2026-01-01 18:21:18 +03:00
|
|
|
|
}
|
2026-01-02 16:15:42 +03:00
|
|
|
|
|
|
|
|
|
|
return response
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// Network errors should not trigger logout
|
|
|
|
|
|
// Let the caller handle the error
|
2026-01-12 17:05:19 +03:00
|
|
|
|
console.error('[Auth] Fetch error for', url, ':', err.message)
|
2026-01-02 16:15:42 +03:00
|
|
|
|
throw err
|
2026-01-01 18:21:18 +03:00
|
|
|
|
}
|
|
|
|
|
|
}, [refreshToken, logout])
|
|
|
|
|
|
|
|
|
|
|
|
const value = {
|
|
|
|
|
|
user,
|
|
|
|
|
|
loading,
|
|
|
|
|
|
error,
|
|
|
|
|
|
login,
|
|
|
|
|
|
register,
|
|
|
|
|
|
logout,
|
|
|
|
|
|
getToken,
|
|
|
|
|
|
authFetch,
|
|
|
|
|
|
isAuthenticated: !!user
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<AuthContext.Provider value={value}>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</AuthContext.Provider>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useAuth() {
|
|
|
|
|
|
const context = useContext(AuthContext)
|
|
|
|
|
|
if (!context) {
|
|
|
|
|
|
throw new Error('useAuth must be used within an AuthProvider')
|
|
|
|
|
|
}
|
|
|
|
|
|
return context
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default AuthContext
|
|
|
|
|
|
|