From 02311f5ead3140cc945e7ed44d3e9a0b10b19138 Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 6 Aug 2026 09:27:15 +0300 Subject: [PATCH 1/2] refactor authentication session recovery --- backend/api/auth/views.py | 21 + backend/api/middleware.py | 7 +- backend/eusrr_backend/channels_jwt.py | 9 +- backend/eusrr_backend/settings.py | 3 +- .../api/auth/test_auth_and_registration.py | 25 ++ frontend/src/app/api/[...path]/route.ts | 8 +- frontend/src/app/attendance/page.tsx | 18 +- frontend/src/components/AppShell.tsx | 25 +- .../attendance/AttendanceDayEventsModal.tsx | 16 +- .../components/documents/DocumentPreview.tsx | 43 +- frontend/src/contexts/UserContext.tsx | 239 ++++++++--- frontend/src/hooks/useWebSocket.ts | 27 +- frontend/src/lib/api/attendance.ts | 37 +- frontend/src/lib/api/calendar.ts | 15 +- frontend/src/lib/api/client.test.js | 167 ++++++++ frontend/src/lib/api/client.ts | 395 ++++++++++-------- frontend/src/lib/api/documents.ts | 35 +- frontend/src/lib/api/equipment.ts | 4 +- frontend/src/lib/api/feed.ts | 51 +-- frontend/src/lib/api/index.ts | 15 +- frontend/src/lib/api/messages.ts | 18 +- frontend/src/lib/api/requests.ts | 11 +- frontend/src/lib/api/utils.ts | 6 +- frontend/src/lib/websocketManager.ts | 39 +- 24 files changed, 813 insertions(+), 421 deletions(-) create mode 100644 frontend/src/lib/api/client.test.js diff --git a/backend/api/auth/views.py b/backend/api/auth/views.py index 3ca54f70..0eb20af5 100644 --- a/backend/api/auth/views.py +++ b/backend/api/auth/views.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import time from datetime import timedelta from django.conf import settings @@ -69,6 +70,7 @@ from .services import ( create_qr_login_token, deny_qr_login_request, exchange_qr_login_token, + extract_client_ip, get_qr_login_request_for_approval, poll_qr_login_request, ) @@ -152,6 +154,25 @@ class PhoneOrEmailTokenObtainPairView(TokenObtainPairView): class JWTTokenRefreshView(TokenRefreshView): serializer_class = SessionTokenRefreshSerializer + def initial(self, request, *args, **kwargs): + self._refresh_started_at = time.monotonic() + return super().initial(request, *args, **kwargs) + + def finalize_response(self, request, response, *args, **kwargs): + duration_ms = round( + (time.monotonic() - getattr(self, "_refresh_started_at", time.monotonic())) + * 1000, + 1, + ) + log = logger.info if response.status_code < 400 else logger.warning + log( + "JWT refresh completed status=%s duration_ms=%s ip=%s", + response.status_code, + duration_ms, + extract_client_ip(request), + ) + return super().finalize_response(request, response, *args, **kwargs) + class QrLoginExchangeAPIView(AnonymousAPIView): @extend_schema( diff --git a/backend/api/middleware.py b/backend/api/middleware.py index 6f1128e9..4608593d 100644 --- a/backend/api/middleware.py +++ b/backend/api/middleware.py @@ -1,7 +1,8 @@ # backend/api/middleware.py -""" -Middleware для автоматического обновления JWT токенов. -Обновляет access token за 5 минут до истечения. +"""Legacy refresh middleware for server-rendered Django session clients. + +The SPA sends Bearer tokens and refreshes them in its own HTTP transport. This +middleware only handles tokens stored under ``api.jwt_*`` in ``request.session``. """ from datetime import datetime, timedelta diff --git a/backend/eusrr_backend/channels_jwt.py b/backend/eusrr_backend/channels_jwt.py index 3402d8bd..fc2081f2 100644 --- a/backend/eusrr_backend/channels_jwt.py +++ b/backend/eusrr_backend/channels_jwt.py @@ -1,6 +1,7 @@ # eusrr_backend/channels_jwt.py from __future__ import annotations +import logging from typing import Optional from urllib.parse import parse_qs @@ -8,6 +9,8 @@ from channels.db import database_sync_to_async from channels.middleware import BaseMiddleware from django.contrib.auth.models import AnonymousUser +logger = logging.getLogger(__name__) + class JWTAuthMiddleware(BaseMiddleware): """Auth для Channels по JWT. @@ -46,7 +49,11 @@ class JWTAuthMiddleware(BaseMiddleware): user, token = await self._authenticate_raw_token(raw) scope["user"] = user scope["auth"] = token - except Exception: + except Exception as error: + logger.info( + "WebSocket JWT authentication rejected error=%s", + type(error).__name__, + ) # оставим scope["user"] анонимным — соединение сам consumer решит pass return await super().__call__(scope, receive, send) diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 0efd1b9a..de58e854 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -84,7 +84,8 @@ MIDDLEWARE = [ "simple_history.middleware.HistoryRequestMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - "api.middleware.JWTRefreshMiddleware", # Автообновление JWT токенов + # Legacy Django-session client only; SPA refresh lives in frontend ApiClient. + "api.middleware.JWTRefreshMiddleware", # Защита от неавторизованных "eusrr_backend.middleware.AuthRequiredMiddleware", "eusrr_backend.middleware.CacheControlMiddleware", # Cache-Control headers diff --git a/backend/tests/api/auth/test_auth_and_registration.py b/backend/tests/api/auth/test_auth_and_registration.py index fab80a39..2f9e2811 100644 --- a/backend/tests/api/auth/test_auth_and_registration.py +++ b/backend/tests/api/auth/test_auth_and_registration.py @@ -685,6 +685,31 @@ def test_sessions_list_and_logout_others_revoke_other_session(api): assert session.revoked_at is not None +def test_refresh_token_is_reusable_without_rotation(api): + assert register(api).status_code == status.HTTP_201_CREATED + code = extract_code_from_last_email() + assert verify(api, email="ivan@example.com", code=code).status_code in (200, 204) + + login = jwt_obtain(api, email="ivan@example.com") + assert login.status_code == status.HTTP_200_OK + refresh = login.json()["refresh"] + + first = api.post( + reverse("api:token_refresh"), + {"refresh": refresh}, + format="json", + ) + second = api.post( + reverse("api:token_refresh"), + {"refresh": refresh}, + format="json", + ) + + assert first.status_code == second.status_code == status.HTTP_200_OK + assert AccessToken(first.json()["access"])["session_id"] + assert AccessToken(second.json()["access"])["session_id"] + + def test_delete_current_session_revokes_access_immediately(api): assert register(api).status_code == status.HTTP_201_CREATED code = extract_code_from_last_email() diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts index 038e377e..51f256b6 100644 --- a/frontend/src/app/api/[...path]/route.ts +++ b/frontend/src/app/api/[...path]/route.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'; const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'https://corp.robotail.pro'; +const PROXY_TIMEOUT_MS = 120_000; export async function GET(request: NextRequest) { return proxyRequest(request, 'GET'); @@ -26,6 +27,8 @@ export async function DELETE(request: NextRequest) { } async function proxyRequest(request: NextRequest, method: string) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort('timeout'), PROXY_TIMEOUT_MS); try { // Получаем путь из URL запроса const url = new URL(request.url); @@ -63,6 +66,7 @@ async function proxyRequest(request: NextRequest, method: string) { method, headers, body, + signal: controller.signal, }); // Копируем заголовки из ответа backend @@ -104,7 +108,9 @@ async function proxyRequest(request: NextRequest, method: string) { console.error('[Proxy Error]', message); return NextResponse.json( { error: 'Ошибка при обращении к backend', message }, - { status: 502 } + { status: controller.signal.aborted ? 504 : 502 } ); + } finally { + clearTimeout(timeoutId); } } diff --git a/frontend/src/app/attendance/page.tsx b/frontend/src/app/attendance/page.tsx index bd123d24..537795e6 100644 --- a/frontend/src/app/attendance/page.tsx +++ b/frontend/src/app/attendance/page.tsx @@ -306,30 +306,16 @@ async function downloadMonthlyAttendanceMatrix( downloadMonthlyAttendanceMatrix?: ( params: AttendanceMatrixExportQuery, ) => Promise; - getToken?: () => string | null; }; if (typeof client.downloadMonthlyAttendanceMatrix === "function") { return client.downloadMonthlyAttendanceMatrix(params); } - const headers: Record = {}; - const token = typeof client.getToken === "function" ? client.getToken() : null; - if (token) headers.Authorization = `Bearer ${token}`; - const response = await fetch( + const response = await apiClient.requestRaw( `/api/v1/attendance/monthly-matrix/export/${buildQuery(params)}`, - { method: "GET", headers }, + { method: "GET" }, ); - if (!response.ok) { - let detail = response.statusText; - try { - const payload = await response.json(); - detail = payload.detail || JSON.stringify(payload); - } catch { - detail = response.statusText; - } - throw new Error(`API Error: ${response.status} ${detail}`); - } const contentDisposition = response.headers.get("content-disposition"); const filename = contentDisposition?.match(/filename="?([^";]+)"?/i)?.[1] || `attendance-${params.period_start}_${params.period_end}.xlsx`; diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 38d01aa5..945ff3fa 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -603,7 +603,7 @@ export function AppShell({ onDesktopWideModeChange, suppressPushOnboardingPrompt = false, }: AppShellProps) { - const { user, loading } = useUser(); + const { user, loading, status: authStatus, error: authError, retryAuthentication } = useUser(); const { mobileNavPlacement } = useMobileNavPlacement(); const router = useRouter(); const pathname = usePathname(); @@ -616,10 +616,10 @@ export function AppShell({ useEffect(() => { // Если загрузка завершена и пользователь не авторизован - редирект на логин - if (!loading && !user) { + if (!loading && authStatus === 'unauthenticated' && !user) { router.push('/login'); } - }, [user, loading, router]); + }, [user, loading, authStatus, router]); // Close mobile drawers on route change useEffect(() => { @@ -673,6 +673,25 @@ export function AppShell({ ); } + if (!user && authStatus === 'recovering') { + return ( +
+
+ +

Восстанавливаем соединение

+

{authError || 'Сервер временно недоступен.'}

+ +
+
+ ); + } + // Если пользователь не авторизован, ничего не показываем (уже редиректим) if (!user) { return null; diff --git a/frontend/src/components/attendance/AttendanceDayEventsModal.tsx b/frontend/src/components/attendance/AttendanceDayEventsModal.tsx index c1475abc..b4f49ca2 100644 --- a/frontend/src/components/attendance/AttendanceDayEventsModal.tsx +++ b/frontend/src/components/attendance/AttendanceDayEventsModal.tsx @@ -49,27 +49,13 @@ function getAttendanceRecordDayEvents(recordId: number) { async function getAttendanceDayEventPhoto(photoUrl: string) { const client = apiClient as typeof apiClient & { getAttendanceDayEventPhoto?: (photoUrl: string) => Promise; - getToken?: () => string | null; }; if (typeof client.getAttendanceDayEventPhoto === "function") { return client.getAttendanceDayEventPhoto(photoUrl); } - const headers: Record = {}; - const token = typeof client.getToken === "function" ? client.getToken() : null; - if (token) headers.Authorization = `Bearer ${token}`; - const response = await fetch(photoUrl, { method: "GET", headers }); - if (!response.ok) { - let detail = response.statusText; - try { - const payload = await response.json(); - detail = payload.detail || payload.error || JSON.stringify(payload); - } catch { - detail = response.statusText; - } - throw new Error(`API Error: ${response.status} ${detail}`); - } + const response = await apiClient.requestRaw(photoUrl); return response.blob(); } diff --git a/frontend/src/components/documents/DocumentPreview.tsx b/frontend/src/components/documents/DocumentPreview.tsx index 8f9e5f54..5f8249b7 100644 --- a/frontend/src/components/documents/DocumentPreview.tsx +++ b/frontend/src/components/documents/DocumentPreview.tsx @@ -6,6 +6,7 @@ import { ChevronLeft, ChevronRight, Download, FileAudio, FileText, FileVideo, Ta import "react-pdf/dist/Page/AnnotationLayer.css"; import "react-pdf/dist/Page/TextLayer.css"; import { Modal } from "@/components/ui"; +import { apiClient } from "@/lib/api"; if (typeof window !== "undefined") { pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"; @@ -108,20 +109,11 @@ function getPreviewType(fileName: string): PreviewType { return "unsupported"; } -function getAuthHeaders(): Record { - if (typeof window === "undefined") return {}; - const token = window.localStorage.getItem("access_token"); - return token ? { Authorization: `Bearer ${token}` } : {}; -} - async function fetchFile(fileUrl: string): Promise { - const response = await fetch(fileUrl, { + const response = await apiClient.requestRaw(fileUrl, { credentials: "include", - headers: getAuthHeaders(), + timeoutMs: 120_000, }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } return response.arrayBuffer(); } @@ -221,12 +213,9 @@ export function DocumentPreviewPane({ fileUrl, fileName, className = "" }: Docum const [excelSheets, setExcelSheets] = useState([]); const [activeSheet, setActiveSheet] = useState(0); const [textContent, setTextContent] = useState(""); + const [pdfData, setPdfData] = useState(null); - const pdfSource = useMemo(() => ({ - url: fileUrl, - httpHeaders: getAuthHeaders(), - withCredentials: true, - }), [fileUrl]); + const pdfSource = useMemo(() => pdfData ? { data: pdfData } : null, [pdfData]); useEffect(() => { let cancelled = false; @@ -238,6 +227,7 @@ export function DocumentPreviewPane({ fileUrl, fileName, className = "" }: Docum setExcelSheets([]); setActiveSheet(0); setTextContent(""); + setPdfData(null); setLoading(["pdf", "image", "word", "spreadsheet", "text"].includes(previewType)); async function loadDocx() { @@ -258,6 +248,19 @@ export function DocumentPreviewPane({ fileUrl, fileName, className = "" }: Docum } } + async function loadPdf() { + try { + const arrayBuffer = await fetchFile(fileUrl); + if (!cancelled) setPdfData(new Uint8Array(arrayBuffer)); + } catch (loadError) { + console.error("PDF preview error:", loadError); + if (!cancelled) { + setError("Не удалось загрузить PDF файл"); + setLoading(false); + } + } + } + async function loadSpreadsheet() { try { const arrayBuffer = await fetchFile(fileUrl); @@ -301,7 +304,9 @@ export function DocumentPreviewPane({ fileUrl, fileName, className = "" }: Docum } } - if (previewType === "word") { + if (previewType === "pdf") { + void loadPdf(); + } else if (previewType === "word") { void loadDocx(); } else if (previewType === "spreadsheet") { void loadSpreadsheet(); @@ -327,7 +332,7 @@ export function DocumentPreviewPane({ fileUrl, fileName, className = "" }: Docum {loading && } {error && }
- { setNumPages(loadedPages); @@ -346,7 +351,7 @@ export function DocumentPreviewPane({ fileUrl, fileName, className = "" }: Docum renderTextLayer className="overflow-hidden rounded-lg shadow-lg" /> - + }
{numPages > 0 && ( diff --git a/frontend/src/contexts/UserContext.tsx b/frontend/src/contexts/UserContext.tsx index 2c6c3856..febea499 100644 --- a/frontend/src/contexts/UserContext.tsx +++ b/frontend/src/contexts/UserContext.tsx @@ -1,86 +1,225 @@ "use client"; -import { createContext, useContext, useEffect, useState, ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useRef, useState, ReactNode } from "react"; import { apiClient } from "@/lib/api"; +import { + ApiError, + AUTH_SESSION_EVENT, + AuthSessionExpiredError, + type AuthSessionEventDetail, + isRecoverableApiError, +} from "@/lib/api/client"; import type { User } from "@/types/api"; +export type AuthStatus = 'loading' | 'authenticated' | 'recovering' | 'unauthenticated'; + interface UserContextType { user: User | null; loading: boolean; + status: AuthStatus; error: string | null; refreshUser: () => Promise; + retryAuthentication: () => Promise; logout: () => void; } const UserContext = createContext(undefined); +const PROACTIVE_REFRESH_WINDOW_MS = 2 * 60 * 1000; +const RECOVERY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000]; export function UserProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); + const [status, setStatus] = useState('loading'); const [error, setError] = useState(null); + const retryTimerRef = useRef(null); + const retryAttemptRef = useRef(0); + const loadUserRef = useRef<() => Promise>(async () => {}); - const loadUser = async () => { - try { - setLoading(true); + const clearRetryTimer = useCallback(() => { + if (retryTimerRef.current !== null) { + window.clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + }, []); + + const scheduleRecovery = useCallback(() => { + clearRetryTimer(); + const index = Math.min(retryAttemptRef.current, RECOVERY_DELAYS_MS.length - 1); + retryAttemptRef.current += 1; + retryTimerRef.current = window.setTimeout(() => { + retryTimerRef.current = null; + void loadUserRef.current(); + }, RECOVERY_DELAYS_MS[index]); + }, [clearRetryTimer]); + + const loadUser = useCallback(async () => { + if (!apiClient.hasStoredSession()) { + clearRetryTimer(); + retryAttemptRef.current = 0; + setUser(null); + setStatus('unauthenticated'); setError(null); + return; + } - // Проверяем наличие токена - const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null; - if (!token) { + setStatus((current) => current === 'recovering' || current === 'authenticated' ? current : 'loading'); + setError(null); + + try { + if (!apiClient.getToken() && apiClient.getRefreshToken()) { + await apiClient.refreshSession(); + } + const userData = await apiClient.getCurrentUser(); + clearRetryTimer(); + retryAttemptRef.current = 0; + setUser(userData); + setStatus('authenticated'); + } catch (caught: unknown) { + const terminal = caught instanceof AuthSessionExpiredError + || (caught instanceof ApiError && (caught.status === 401 || caught.status === 403)); + + if (terminal) { + clearRetryTimer(); + retryAttemptRef.current = 0; + apiClient.clearToken('expired'); setUser(null); - setLoading(false); + setStatus('unauthenticated'); + setError(null); return; } - // Загружаем данные пользователя - // ApiClient автоматически обновит токен если он истёк - const userData = await apiClient.getCurrentUser(); - setUser(userData); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - console.error('Ошибка загрузки пользователя:', err); + const message = caught instanceof Error ? caught.message : String(caught); + console.error('Ошибка восстановления авторизации:', caught); setError(message); - setUser(null); + setStatus('recovering'); + scheduleRecovery(); + } + }, [clearRetryTimer, scheduleRecovery]); - // Токены уже очищены в ApiClient если refresh token тоже истёк - // Проверяем, остались ли токены - если нет, значит сессия истекла - const hasToken = typeof window !== 'undefined' && localStorage.getItem('access_token'); - if (!hasToken) { - // Токены очищены - сессия истекла, redirect на login - if ( - typeof window !== 'undefined' && - !['/login', '/login/qr', '/login/qr/confirm'].includes(window.location.pathname) - ) { - window.location.href = '/login'; + useEffect(() => { + loadUserRef.current = loadUser; + }, [loadUser]); + + const retryAuthentication = useCallback(async () => { + clearRetryTimer(); + await loadUser(); + }, [clearRetryTimer, loadUser]); + + const refreshUser = useCallback(async () => { + await loadUser(); + }, [loadUser]); + + const logout = useCallback(() => { + clearRetryTimer(); + apiClient.clearToken('logout'); + setUser(null); + setStatus('unauthenticated'); + if (typeof window !== 'undefined') window.location.href = '/login'; + }, [clearRetryTimer]); + + useEffect(() => { + let active = true; + queueMicrotask(() => { + if (active) void loadUser(); + }); + return () => { + active = false; + clearRetryTimer(); + }; + }, [clearRetryTimer, loadUser]); + + useEffect(() => { + const handleAuthEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail?.type === 'session-cleared') { + clearRetryTimer(); + setUser(null); + setStatus('unauthenticated'); + setError(null); + } else if (detail?.type === 'recovery-required') { + setError(detail.message); + setStatus('recovering'); + scheduleRecovery(); + } + }; + const handleStorage = (event: StorageEvent) => { + if (!['access_token', 'refresh_token'].includes(event.key || '')) return; + if (!apiClient.hasStoredSession()) { + clearRetryTimer(); + setUser(null); + setStatus('unauthenticated'); + setError(null); + } else if (!user) { + void loadUserRef.current(); + } + }; + + window.addEventListener(AUTH_SESSION_EVENT, handleAuthEvent); + window.addEventListener('storage', handleStorage); + return () => { + window.removeEventListener(AUTH_SESSION_EVENT, handleAuthEvent); + window.removeEventListener('storage', handleStorage); + }; + }, [clearRetryTimer, scheduleRecovery, user]); + + useEffect(() => { + if (!user || status === 'unauthenticated') return; + + let timerId: number | null = null; + const refreshAccess = async () => { + try { + await apiClient.refreshSession(); + retryAttemptRef.current = 0; + setStatus('authenticated'); + setError(null); + scheduleProactiveRefresh(); + } catch (caught: unknown) { + if (caught instanceof AuthSessionExpiredError) { + setUser(null); + setStatus('unauthenticated'); + return; + } + if (isRecoverableApiError(caught)) { + setError(caught instanceof Error ? caught.message : String(caught)); + setStatus('recovering'); + scheduleRecovery(); } } - } finally { - setLoading(false); - } - }; + }; + const scheduleProactiveRefresh = () => { + if (timerId !== null) window.clearTimeout(timerId); + const expiresAt = apiClient.getAccessTokenExpiresAt(); + if (!expiresAt) return; + const delay = Math.max(1_000, expiresAt - Date.now() - PROACTIVE_REFRESH_WINDOW_MS); + timerId = window.setTimeout(() => void refreshAccess(), delay); + }; + const handleVisibility = () => { + if (document.visibilityState !== 'visible') return; + const expiresAt = apiClient.getAccessTokenExpiresAt(); + if (expiresAt !== null && expiresAt - Date.now() <= PROACTIVE_REFRESH_WINDOW_MS) { + void refreshAccess(); + } else { + scheduleProactiveRefresh(); + } + }; - const refreshUser = async () => { - await loadUser(); - }; - - const logout = () => { - apiClient.clearToken(); - setUser(null); - if (typeof window !== 'undefined') { - window.location.href = '/login'; - } - }; - - // Загружаем пользователя при монтировании - useEffect(() => { - loadUser(); - }, []); + scheduleProactiveRefresh(); + document.addEventListener('visibilitychange', handleVisibility); + window.addEventListener('focus', handleVisibility); + return () => { + if (timerId !== null) window.clearTimeout(timerId); + document.removeEventListener('visibilitychange', handleVisibility); + window.removeEventListener('focus', handleVisibility); + }; + }, [scheduleRecovery, status, user]); const value: UserContextType = { user, - loading, + loading: status === 'loading', + status, error, refreshUser, + retryAuthentication, logout, }; @@ -89,8 +228,6 @@ export function UserProvider({ children }: { children: ReactNode }) { export function useUser() { const context = useContext(UserContext); - if (context === undefined) { - throw new Error('useUser must be used within a UserProvider'); - } + if (!context) throw new Error('useUser must be used within UserProvider'); return context; } diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 52948aab..a27f0d16 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { getWebSocketUrl } from '@/lib/url'; import type { Message } from '@/types/api'; +import { AUTH_SESSION_EVENT, type AuthSessionEventDetail } from '@/lib/api/client'; type ReactionsSummary = Record; @@ -93,7 +94,6 @@ export function useWebSocket({ wsUrl += `${wsUrl.includes('?') ? '&' : '?'}token=${token}`; } - console.log('🔄 WebSocket URL:', wsUrl); const ws = new WebSocket(wsUrl); wsRef.current = ws; @@ -235,6 +235,31 @@ export function useWebSocket({ }; }, [chatId, autoConnect, connect, disconnect]); + useEffect(() => { + const reconnectWithLatestToken = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail?.type === 'session-cleared') { + disconnect(); + return; + } + if (detail?.type !== 'token-refreshed' || !autoConnect || !chatId) return; + + const previousSocket = wsRef.current; + wsRef.current = null; + if (previousSocket) { + previousSocket.onclose = null; + previousSocket.close(); + } + isConnectingRef.current = false; + reconnectAttemptsRef.current = 0; + shouldReconnectRef.current = true; + connectRef.current(); + }; + + window.addEventListener(AUTH_SESSION_EVENT, reconnectWithLatestToken); + return () => window.removeEventListener(AUTH_SESSION_EVENT, reconnectWithLatestToken); + }, [autoConnect, chatId, disconnect]); + // Очистка при размонтировании useEffect(() => { return () => { diff --git a/frontend/src/lib/api/attendance.ts b/frontend/src/lib/api/attendance.ts index bca3a9f0..acd4bc53 100644 --- a/frontend/src/lib/api/attendance.ts +++ b/frontend/src/lib/api/attendance.ts @@ -1,4 +1,4 @@ -import { buildQuery, type GetTokenFn, type RequestFn } from './utils'; +import { buildQuery, type RawRequestFn, type RequestFn } from './utils'; import type { MessageLinkedTask, User } from '@/types/api'; export type AttendanceDateOverridePayload = { @@ -260,7 +260,7 @@ function filenameFromContentDisposition(value: string | null): string | null { return match?.[1] || null; } -export function createAttendanceApi(request: RequestFn, getToken: GetTokenFn) { +export function createAttendanceApi(request: RequestFn, requestRaw: RawRequestFn) { return { analyzeAttendance: (data: AttendanceAnalyzePayload) => request('/api/v1/attendance/logstorm/analyze/', { @@ -278,23 +278,7 @@ export function createAttendanceApi(request: RequestFn, getToken: GetTokenFn) { downloadMonthlyAttendanceMatrix: async ( params: MonthlyAttendanceMatrixExportQuery, ): Promise => { - const token = getToken(); - const headers: Record = {}; - if (token) headers.Authorization = `Bearer ${token}`; - const response = await fetch(`/api/v1/attendance/monthly-matrix/export/${buildQuery(params)}`, { - method: 'GET', - headers, - }); - if (!response.ok) { - let detail = response.statusText; - try { - const payload = await response.json(); - detail = payload.detail || JSON.stringify(payload); - } catch { - detail = response.statusText; - } - throw new Error(`API Error: ${response.status} ${detail}`); - } + const response = await requestRaw(`/api/v1/attendance/monthly-matrix/export/${buildQuery(params)}`, { timeoutMs: 120_000 }); return { blob: await response.blob(), filename: filenameFromContentDisposition( @@ -345,20 +329,7 @@ export function createAttendanceApi(request: RequestFn, getToken: GetTokenFn) { getAttendanceRecordDayEvents: (recordId: number) => request(`/api/v1/attendance/records/${recordId}/day-events/`) as Promise, getAttendanceDayEventPhoto: async (photoUrl: string): Promise => { - const token = getToken(); - const headers: Record = {}; - if (token) headers.Authorization = `Bearer ${token}`; - const response = await fetch(photoUrl, { method: 'GET', headers }); - if (!response.ok) { - let detail = response.statusText; - try { - const payload = await response.json(); - detail = payload.detail || payload.error || JSON.stringify(payload); - } catch { - detail = response.statusText; - } - throw new Error(`API Error: ${response.status} ${detail}`); - } + const response = await requestRaw(photoUrl); return response.blob(); }, addAttendanceRecordComment: (recordId: number, text: string) => diff --git a/frontend/src/lib/api/calendar.ts b/frontend/src/lib/api/calendar.ts index 385b7d1a..d2521a98 100644 --- a/frontend/src/lib/api/calendar.ts +++ b/frontend/src/lib/api/calendar.ts @@ -1,4 +1,4 @@ -import type { RequestFn, GetTokenFn } from './utils'; +import type { RawRequestFn, RequestFn } from './utils'; import type { Calendar, CalendarEvent, @@ -13,7 +13,7 @@ type CalendarListResponse = PaginatedResponse | Calendar[]; type CalendarEventsResponse = PaginatedResponse | CalendarEvent[]; type CalendarRulesParams = Record; -export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) { +export function createCalendarApi(request: RequestFn, requestRaw: RawRequestFn) { return { getCalendarEvents: (params?: CalendarQueryParams): Promise => { const qp = new URLSearchParams(); @@ -66,15 +66,16 @@ export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) { removeEventParticipant: (relationId: number) => request(`/api/v1/schedule/relations/${relationId}/`, { method: 'DELETE' }), // Export/Import exportCalendarToICS: async (calendarId: number): Promise => { - const response = await fetch(`/api/v1/schedule/calendars/${calendarId}/export-ical/`, { method: 'GET', headers: { 'Authorization': `Bearer ${getToken()}` } }); - if (!response.ok) throw new Error('Failed to export calendar'); + const response = await requestRaw(`/api/v1/schedule/calendars/${calendarId}/export-ical/`, { timeoutMs: 120_000 }); return response.blob(); }, importCalendarFromICS: async (calendarId: number, file: File) => { const fd = new FormData(); fd.append('file', file); - const response = await fetch(`/api/v1/schedule/calendars/${calendarId}/import-ical/`, { method: 'POST', headers: { 'Authorization': `Bearer ${getToken()}` }, body: fd }); - if (!response.ok) { const e = await response.json(); throw new Error(e.error || 'Failed to import calendar'); } - return response.json(); + return request(`/api/v1/schedule/calendars/${calendarId}/import-ical/`, { + method: 'POST', + body: fd, + timeoutMs: 120_000, + }); }, // Rules createRule: (data: { name: string; description?: string; frequency: string; params?: CalendarRulesParams }) => diff --git a/frontend/src/lib/api/client.test.js b/frontend/src/lib/api/client.test.js new file mode 100644 index 00000000..d3a7a3a5 --- /dev/null +++ b/frontend/src/lib/api/client.test.js @@ -0,0 +1,167 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import ts from 'typescript'; + +const source = await readFile(new URL('./client.ts', import.meta.url), 'utf8'); +const compiled = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 }, +}).outputText; +const { + ApiClientBase, + AuthRecoveryError, + AuthSessionExpiredError, + ApiNetworkError, +} = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`); + +class MemoryStorage { + #values = new Map(); + + getItem(key) { + return this.#values.get(key) ?? null; + } + + setItem(key, value) { + this.#values.set(key, String(value)); + } + + removeItem(key) { + this.#values.delete(key); + } + + clear() { + this.#values.clear(); + } +} + +function installBrowserGlobals() { + const storage = new MemoryStorage(); + const target = new EventTarget(); + target.setTimeout = setTimeout; + target.clearTimeout = clearTimeout; + globalThis.window = target; + globalThis.localStorage = storage; + if (typeof globalThis.CustomEvent === 'undefined') { + globalThis.CustomEvent = class CustomEvent extends Event { + constructor(type, init = {}) { + super(type); + this.detail = init.detail; + } + }; + } + return storage; +} + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +test('parallel 401 responses share one refresh and both requests resume', async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { globalThis.fetch = originalFetch; }); + const storage = installBrowserGlobals(); + storage.setItem('access_token', 'expired-access'); + storage.setItem('refresh_token', 'valid-refresh'); + + let refreshCalls = 0; + globalThis.fetch = async (url, options = {}) => { + if (url === '/api/auth/token/refresh/') { + refreshCalls += 1; + await new Promise((resolve) => setTimeout(resolve, 10)); + return jsonResponse({ access: 'fresh-access' }); + } + if (url === '/resource') { + return options.headers.Authorization === 'Bearer fresh-access' + ? jsonResponse({ ok: true }) + : jsonResponse({ detail: 'expired' }, 401); + } + throw new Error(`Unexpected URL: ${url}`); + }; + + const client = new ApiClientBase(); + const [first, second] = await Promise.all([ + client.request('/resource'), + client.request('/resource'), + ]); + + assert.deepEqual(first, { ok: true }); + assert.deepEqual(second, { ok: true }); + assert.equal(refreshCalls, 1); + assert.equal(storage.getItem('access_token'), 'fresh-access'); +}); + +test('transient refresh failure rejects every waiter and preserves the session', async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { globalThis.fetch = originalFetch; }); + const storage = installBrowserGlobals(); + storage.setItem('access_token', 'expired-access'); + storage.setItem('refresh_token', 'valid-refresh'); + + let refreshCalls = 0; + globalThis.fetch = async (url) => { + if (url === '/api/auth/token/refresh/') { + refreshCalls += 1; + await new Promise((resolve) => setTimeout(resolve, 10)); + return jsonResponse({ detail: 'temporary outage' }, 502); + } + return jsonResponse({ detail: 'expired' }, 401); + }; + + const client = new ApiClientBase(); + const settled = await Promise.race([ + Promise.allSettled([client.request('/one'), client.request('/two')]), + new Promise((resolve) => setTimeout(() => resolve('still-pending'), 500)), + ]); + + assert.notEqual(settled, 'still-pending'); + assert.equal(refreshCalls, 1); + for (const result of settled) { + assert.equal(result.status, 'rejected'); + assert.ok(result.reason instanceof AuthRecoveryError); + } + assert.equal(storage.getItem('access_token'), 'expired-access'); + assert.equal(storage.getItem('refresh_token'), 'valid-refresh'); +}); + +test('confirmed invalid refresh clears tokens and expires the session', async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { globalThis.fetch = originalFetch; }); + const storage = installBrowserGlobals(); + storage.setItem('access_token', 'expired-access'); + storage.setItem('refresh_token', 'invalid-refresh'); + + globalThis.fetch = async (url) => { + if (url === '/api/auth/token/refresh/') { + return jsonResponse({ detail: 'token_not_valid' }, 401); + } + return jsonResponse({ detail: 'expired' }, 401); + }; + + const client = new ApiClientBase(); + await assert.rejects(client.request('/resource'), AuthSessionExpiredError); + assert.equal(storage.getItem('access_token'), null); + assert.equal(storage.getItem('refresh_token'), null); +}); + +test('request timeout terminates the request without deleting tokens', async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { globalThis.fetch = originalFetch; }); + const storage = installBrowserGlobals(); + storage.setItem('access_token', 'access'); + storage.setItem('refresh_token', 'refresh'); + + globalThis.fetch = async (_url, options = {}) => new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + }); + + const client = new ApiClientBase(); + await assert.rejects( + client.request('/slow', { timeoutMs: 20 }), + ApiNetworkError, + ); + assert.equal(storage.getItem('access_token'), 'access'); + assert.equal(storage.getItem('refresh_token'), 'refresh'); +}); diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 18a8c9d1..e5956096 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -1,6 +1,5 @@ /** - * Base API Client — token management, auth, HTTP request logic. - * Domain-specific methods live in separate modules. + * Base API client: authentication state, token refresh and HTTP transport. */ import type { @@ -15,76 +14,221 @@ interface LoginCredentials { password: string; } +export type ApiRequestOptions = RequestInit & { + timeoutMs?: number; +}; + +export const AUTH_SESSION_EVENT = 'eusrr:auth-session'; + +export type AuthSessionEventDetail = + | { type: 'token-refreshed' } + | { type: 'recovery-required'; message: string } + | { type: 'session-cleared'; reason: 'expired' | 'logout' }; + +export class ApiError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = 'ApiError'; + } +} + +export class ApiNetworkError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ApiNetworkError'; + } +} + +export class AuthSessionExpiredError extends Error { + constructor(message = 'Сессия истекла. Войдите снова.') { + super(message); + this.name = 'AuthSessionExpiredError'; + } +} + +export class AuthRecoveryError extends Error { + constructor(message = 'Не удалось обновить сессию. Повторяем подключение.', options?: ErrorOptions) { + super(message, options); + this.name = 'AuthRecoveryError'; + } +} + +export function isRecoverableApiError(error: unknown): boolean { + return error instanceof AuthRecoveryError + || error instanceof ApiNetworkError + || (error instanceof ApiError && (error.status === 408 || error.status === 425 || error.status === 429 || error.status >= 500)); +} + +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const AUTH_REQUEST_TIMEOUT_MS = 15_000; + export class ApiClientBase { - private tokenKey = 'access_token'; - private refreshTokenKey = 'refresh_token'; - private isRefreshing = false; - private refreshSubscribers: Array<(token: string) => void> = []; + private readonly tokenKey = 'access_token'; + private readonly refreshTokenKey = 'refresh_token'; + private refreshPromise: Promise | null = null; getToken(): string | null { if (typeof window === 'undefined') return null; return localStorage.getItem(this.tokenKey); } - private getRefreshToken(): string | null { + getRefreshToken(): string | null { if (typeof window === 'undefined') return null; return localStorage.getItem(this.refreshTokenKey); } - private setToken(token: string): void { - if (typeof window !== 'undefined') { - localStorage.setItem(this.tokenKey, token); + hasStoredSession(): boolean { + return Boolean(this.getToken() || this.getRefreshToken()); + } + + getAccessTokenExpiresAt(): number | null { + const token = this.getToken(); + if (!token) return null; + + try { + const payload = token.split('.')[1]; + if (!payload) return null; + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + const decoded = JSON.parse(atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='))); + return typeof decoded.exp === 'number' ? decoded.exp * 1000 : null; + } catch { + return null; } } + private emitAuthEvent(detail: AuthSessionEventDetail): void { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(AUTH_SESSION_EVENT, { detail })); + } + } + + private setToken(token: string): void { + if (typeof window === 'undefined') return; + localStorage.setItem(this.tokenKey, token); + this.emitAuthEvent({ type: 'token-refreshed' }); + } + private setRefreshToken(token: string): void { if (typeof window !== 'undefined') { localStorage.setItem(this.refreshTokenKey, token); } } - clearToken(): void { - if (typeof window !== 'undefined') { - localStorage.removeItem(this.tokenKey); - localStorage.removeItem(this.refreshTokenKey); + clearToken(reason: 'expired' | 'logout' = 'logout'): void { + if (typeof window === 'undefined') return; + localStorage.removeItem(this.tokenKey); + localStorage.removeItem(this.refreshTokenKey); + this.emitAuthEvent({ type: 'session-cleared', reason }); + } + + private recoveryError(message?: string, cause?: unknown): AuthRecoveryError { + const error = new AuthRecoveryError(message, cause === undefined ? undefined : { cause }); + this.emitAuthEvent({ type: 'recovery-required', message: error.message }); + return error; + } + + private async fetchWithTimeout( + endpoint: string, + options: ApiRequestOptions = {}, + fallbackTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + ): Promise { + const { timeoutMs = fallbackTimeoutMs, signal, ...requestOptions } = options; + const controller = new AbortController(); + const abortFromCaller = () => controller.abort(signal?.reason); + + if (signal?.aborted) { + abortFromCaller(); + } else { + signal?.addEventListener('abort', abortFromCaller, { once: true }); + } + + const timeoutId = window.setTimeout(() => controller.abort('timeout'), timeoutMs); + + try { + return await fetch(endpoint, { ...requestOptions, signal: controller.signal }); + } catch (error) { + if (signal?.aborted) throw error; + const message = controller.signal.aborted + ? 'Превышено время ожидания ответа сервера.' + : 'Не удалось соединиться с сервером.'; + throw new ApiNetworkError(message, { cause: error }); + } finally { + window.clearTimeout(timeoutId); + signal?.removeEventListener('abort', abortFromCaller); } } - private onRefreshed(token: string): void { - this.refreshSubscribers.forEach((callback) => callback(token)); - this.refreshSubscribers = []; - } - - private addRefreshSubscriber(callback: (token: string) => void): void { - this.refreshSubscribers.push(callback); - } - - private async refreshAccessToken(): Promise { + private async performRefresh(): Promise { const refreshToken = this.getRefreshToken(); if (!refreshToken) { - throw new Error('No refresh token available'); + this.clearToken('expired'); + throw new AuthSessionExpiredError(); } - const response = await fetch('/api/auth/token/refresh/', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh: refreshToken }), - }); + let response: Response; + try { + response = await this.fetchWithTimeout('/api/auth/token/refresh/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh: refreshToken }), + timeoutMs: AUTH_REQUEST_TIMEOUT_MS, + }); + } catch (error) { + throw this.recoveryError(undefined, error); + } + + if (response.status === 400 || response.status === 401) { + this.clearToken('expired'); + throw new AuthSessionExpiredError(); + } if (!response.ok) { - this.clearToken(); - throw new Error('Refresh token expired'); + throw this.recoveryError(`Сервер временно не может обновить сессию (HTTP ${response.status}).`); } - const data: LoginResponse = await response.json(); - this.setToken(data.access); - if (data.refresh) { - this.setRefreshToken(data.refresh); + let data: LoginResponse; + try { + data = await response.json(); + } catch (error) { + throw this.recoveryError('Сервер вернул некорректный ответ при обновлении сессии.', error); } + + if (!data.access) { + throw this.recoveryError('В ответе обновления сессии отсутствует access-токен.'); + } + + if (data.refresh) this.setRefreshToken(data.refresh); + this.setToken(data.access); return data.access; } - private buildHeaders(options: RequestInit): Record { + refreshSession(): Promise { + if (!this.refreshPromise) { + const accessBeforeRefresh = this.getToken(); + const runRefresh = async () => { + const currentAccess = this.getToken(); + if (currentAccess && currentAccess !== accessBeforeRefresh) { + return currentAccess; + } + return this.performRefresh(); + }; + const refresh: Promise = typeof navigator !== 'undefined' && navigator.locks + ? navigator.locks.request('eusrr-auth-refresh', runRefresh) as unknown as Promise + : runRefresh(); + + const pendingRefresh = refresh.finally(() => { + this.refreshPromise = null; + }); + this.refreshPromise = pendingRefresh; + return pendingRefresh; + } + return this.refreshPromise; + } + + private buildHeaders(options: RequestInit, token = this.getToken()): Record { const headers: Record = { ...(options.headers as Record), }; @@ -93,11 +237,7 @@ export class ApiClientBase { headers['Content-Type'] = 'application/json'; } - const token = this.getToken(); - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - + if (token) headers.Authorization = `Bearer ${token}`; return headers; } @@ -110,173 +250,98 @@ export class ApiClientBase { } } - async requestRaw(endpoint: string, options: RequestInit = {}): Promise { - const headers = this.buildHeaders(options); - const response = await fetch(endpoint, { ...options, headers }); + private async execute(endpoint: string, options: ApiRequestOptions): Promise { + let response = await this.fetchWithTimeout(endpoint, { + ...options, + headers: this.buildHeaders(options), + }); if (response.status === 401 && this.getRefreshToken()) { - if (!this.isRefreshing) { - this.isRefreshing = true; - try { - const newToken = await this.refreshAccessToken(); - this.isRefreshing = false; - this.onRefreshed(newToken); - - headers['Authorization'] = `Bearer ${newToken}`; - const retryResponse = await fetch(endpoint, { ...options, headers }); - - if (!retryResponse.ok) { - const errorDetails = await this.parseErrorDetails(retryResponse); - throw new Error(`API Error: ${retryResponse.status} ${errorDetails}`); - } - - return retryResponse; - } catch (refreshError) { - this.isRefreshing = false; - this.refreshSubscribers = []; - throw refreshError; - } - } - - return new Promise((resolve, reject) => { - this.addRefreshSubscriber((newToken: string) => { - headers['Authorization'] = `Bearer ${newToken}`; - fetch(endpoint, { ...options, headers }) - .then(async (res) => { - if (!res.ok) { - const errorDetails = await this.parseErrorDetails(res); - reject(new Error(`API Error: ${res.status} ${errorDetails}`)); - return; - } - resolve(res); - }) - .catch(reject); - }); + const newToken = await this.refreshSession(); + response = await this.fetchWithTimeout(endpoint, { + ...options, + headers: this.buildHeaders(options, newToken), }); } - if (!response.ok) { - const errorDetails = await this.parseErrorDetails(response); - throw new Error(`API Error: ${response.status} ${errorDetails}`); - } - return response; } - async request(endpoint: string, options: RequestInit = {}): Promise { - const headers = this.buildHeaders(options); - const response = await fetch(endpoint, { ...options, headers }); - - if (response.status === 401 && this.getRefreshToken()) { - if (!this.isRefreshing) { - this.isRefreshing = true; - try { - const newToken = await this.refreshAccessToken(); - this.isRefreshing = false; - this.onRefreshed(newToken); - - headers['Authorization'] = `Bearer ${newToken}`; - const retryResponse = await fetch(endpoint, { ...options, headers }); - - if (!retryResponse.ok) { - const errorDetails = await this.parseErrorDetails(retryResponse); - throw new Error(`API Error: ${retryResponse.status} ${errorDetails}`); - } - if (options.method === 'DELETE' && retryResponse.status === 204) return undefined as T; - const cl = retryResponse.headers.get('content-length'); - if (cl === '0' || retryResponse.status === 204) return undefined as T; - return retryResponse.json(); - } catch (refreshError) { - this.isRefreshing = false; - this.refreshSubscribers = []; - throw refreshError; - } - } else { - return new Promise((resolve, reject) => { - this.addRefreshSubscriber((newToken: string) => { - headers['Authorization'] = `Bearer ${newToken}`; - fetch(endpoint, { ...options, headers }) - .then((res) => { - if (!res.ok) { reject(new Error(`API Error: ${res.status}`)); return; } - if (options.method === 'DELETE' && res.status === 204) { resolve(undefined as T); return; } - return res.json(); - }) - .then((data) => resolve(data)) - .catch(reject); - }); - }); - } - } - + async requestRaw(endpoint: string, options: ApiRequestOptions = {}): Promise { + const response = await this.execute(endpoint, options); if (!response.ok) { - const errorDetails = await this.parseErrorDetails(response); - throw new Error(`API Error: ${response.status} ${errorDetails}`); + const details = await this.parseErrorDetails(response); + throw new ApiError(response.status, `API Error: ${response.status} ${details}`); } + return response; + } - if (options.method === 'DELETE' && response.status === 204) return undefined as T; - const contentLength = response.headers.get('content-length'); - if (contentLength === '0' || response.status === 204) return undefined as T; + async request(endpoint: string, options: ApiRequestOptions = {}): Promise { + const response = await this.requestRaw(endpoint, options); + if (response.status === 204 || response.headers.get('content-length') === '0') { + return undefined as T; + } return response.json(); } - // ── Auth ────────────────────────────────────────── + private async unauthenticatedRequest(endpoint: string, options: ApiRequestOptions): Promise { + const response = await this.fetchWithTimeout(endpoint, options, AUTH_REQUEST_TIMEOUT_MS); + if (!response.ok) { + const details = await this.parseErrorDetails(response); + throw new ApiError(response.status, `API Error: ${response.status} ${details}`); + } + if (response.status === 204) return undefined as T; + return response.json(); + } + + // Auth async login(credentials: LoginCredentials): Promise { - const response = await fetch('/api/auth/token/', { + const data = await this.unauthenticatedRequest('/api/auth/token/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), }); - if (!response.ok) throw new Error(`Login failed: ${response.status}`); - const data: LoginResponse = await response.json(); - this.setToken(data.access); this.setRefreshToken(data.refresh); + this.setToken(data.access); } async loginWithQrToken(token: string): Promise { - const response = await fetch('/api/auth/qr-login/exchange/', { + const data = await this.unauthenticatedRequest('/api/auth/qr-login/exchange/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), }); - if (!response.ok) throw new Error(`QR login failed: ${response.status}`); - const data: LoginResponse = await response.json(); - this.setToken(data.access); this.setRefreshToken(data.refresh); + this.setToken(data.access); } async createQrLoginRequest(): Promise { - const response = await fetch('/api/auth/qr-login/requests/', { + return this.unauthenticatedRequest('/api/auth/qr-login/requests/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); - if (!response.ok) throw new Error(`QR request failed: ${response.status}`); - return response.json(); } async pollQrLoginRequest(clientSecret: string): Promise { - const response = await fetch('/api/auth/qr-login/requests/status/', { + const data = await this.unauthenticatedRequest('/api/auth/qr-login/requests/status/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_secret: clientSecret }), }); - if (!response.ok) throw new Error(`QR status failed: ${response.status}`); - - const data: QrLoginRequestStatusResult = await response.json(); if (data.status === 'approved' && data.access && data.refresh) { - this.setToken(data.access); this.setRefreshToken(data.refresh); + this.setToken(data.access); } return data; } async cancelQrLoginRequest(clientSecret: string): Promise { - const response = await fetch('/api/auth/qr-login/requests/cancel/', { + await this.unauthenticatedRequest('/api/auth/qr-login/requests/cancel/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_secret: clientSecret }), }); - if (!response.ok) throw new Error(`QR cancel failed: ${response.status}`); } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -295,7 +360,6 @@ export class ApiClientBase { // eslint-disable-next-line @typescript-eslint/no-explicit-any async updateCurrentUserProfile(data: Record): Promise { const hasFile = Object.values(data).some((value) => value instanceof File); - if (!hasFile) { return this.request('/api/v1/employees/me/', { method: 'PATCH', @@ -309,15 +373,16 @@ export class ApiClientBase { data[key].forEach((value: unknown) => { if (value !== null && value !== undefined) formData.append(key, String(value)); }); + } else if (data[key] instanceof File) { + formData.append(key, data[key]); + } else if (data[key] !== null && data[key] !== undefined) { + formData.append(key, data[key]); } - else if (data[key] instanceof File) { formData.append(key, data[key]); } - else if (data[key] !== null && data[key] !== undefined) { formData.append(key, data[key]); } }); - const token = this.getToken(); - const headers: Record = {}; - if (token) headers['Authorization'] = `Bearer ${token}`; - const response = await fetch('/api/v1/employees/me/', { method: 'PATCH', headers, body: formData }); - if (!response.ok) throw new Error(`API Error: ${response.status} ${response.statusText}`); - return response.json(); + return this.request('/api/v1/employees/me/', { + method: 'PATCH', + body: formData, + timeoutMs: 120_000, + }); } } diff --git a/frontend/src/lib/api/documents.ts b/frontend/src/lib/api/documents.ts index 0bd14b09..e6be81fe 100644 --- a/frontend/src/lib/api/documents.ts +++ b/frontend/src/lib/api/documents.ts @@ -1,4 +1,4 @@ -import type { GetTokenFn, RawRequestFn, RequestFn } from './utils'; +import type { RawRequestFn, RequestFn } from './utils'; function parseDownloadFilename(response: Response, fallback: string): string { const disposition = response.headers.get('content-disposition') || ''; @@ -13,30 +13,9 @@ async function fetchDownload( endpoint: string, options: RequestInit, fallbackFilename: string, - requestRaw?: RawRequestFn, - getToken?: GetTokenFn + requestRaw: RawRequestFn, ): Promise<{ blob: Blob; filename: string }> { - let response: Response; - - if (requestRaw) { - response = await requestRaw(endpoint, options); - } else { - const headers: Record = { - ...(options.headers as Record), - }; - const token = getToken?.(); - if (token) headers.Authorization = `Bearer ${token}`; - response = await fetch(endpoint, { ...options, headers }); - - if (!response.ok) { - let errorDetails = response.statusText; - try { - const data = await response.json(); - errorDetails = data.detail || JSON.stringify(data); - } catch {} - throw new Error(`API Error: ${response.status} ${errorDetails}`); - } - } + const response = await requestRaw(endpoint, { ...options, timeoutMs: 120_000 }); return { blob: await response.blob(), @@ -44,7 +23,7 @@ async function fetchDownload( }; } -export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, requestRaw?: RawRequestFn) { +export function createDocumentsApi(request: RequestFn, requestRaw: RawRequestFn) { return { getDocuments: (params?: { search?: string; type?: string; status?: string; scope?: string; page?: number; page_size?: number; limit?: number; folder_id?: number; is_regulation?: boolean }) => { const qp = new URLSearchParams(); @@ -107,8 +86,7 @@ export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, re body: JSON.stringify({ document_ids: ids }), }, 'documents.zip', - requestRaw, - getToken + requestRaw ); }, acknowledgeDocument: (id: number) => request(`/api/v1/documents/${id}/acknowledge/`, { method: 'POST', body: JSON.stringify({}) }), @@ -144,8 +122,7 @@ export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, re `/api/v1/folders/${id}/archive/`, {}, `folder-${id}.zip`, - requestRaw, - getToken + requestRaw ); }, getFolderChildren: (id: number) => request(`/api/v1/folders/${id}/children/`), diff --git a/frontend/src/lib/api/equipment.ts b/frontend/src/lib/api/equipment.ts index d1b81063..2cac00db 100644 --- a/frontend/src/lib/api/equipment.ts +++ b/frontend/src/lib/api/equipment.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { buildQuery, type RequestFn, type GetTokenFn } from './utils'; +import { buildQuery, type RequestFn } from './utils'; -export function createEquipmentApi(request: RequestFn, getToken: GetTokenFn) { +export function createEquipmentApi(request: RequestFn) { return { getEquipmentCategories: (params?: Record) => request(`/api/v1/procurement/equipment-categories/${buildQuery(params)}`), createEquipmentCategory: (data: { name: string; parent?: number | null; description?: string; icon?: string }) => diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts index d96c48a1..1b8441df 100644 --- a/frontend/src/lib/api/feed.ts +++ b/frontend/src/lib/api/feed.ts @@ -1,30 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { RequestFn, GetTokenFn } from './utils'; +import type { RequestFn } from './utils'; -export function createFeedApi(request: RequestFn, getToken: GetTokenFn) { +export function createFeedApi(request: RequestFn) { type PinScope = 'global' | 'department'; - async function extractErrorDetails(response: Response): Promise { - const contentType = response.headers.get('content-type') || ''; - try { - if (contentType.includes('application/json')) { - const data = await response.json(); - if (typeof data?.detail === 'string' && data.detail.trim()) return data.detail; - const firstEntry = Object.entries(data || {})[0]; - if (firstEntry) { - const value = firstEntry[1]; - if (Array.isArray(value) && value[0]) return JSON.stringify(data); - if (typeof value === 'string' && value.trim()) return JSON.stringify(data); - } - return JSON.stringify(data); - } - const text = await response.text(); - return text || response.statusText; - } catch { - return response.statusText; - } - } - async function formDataPost(url: string, method: string, data: Record) { const fd = new FormData(); if (data.title) fd.append('title', data.title); @@ -35,16 +14,7 @@ export function createFeedApi(request: RequestFn, getToken: GetTokenFn) { if (data.image) fd.append('image', data.image); if (data.attachment) fd.append('attachment', data.attachment); if (data.attachments) data.attachments.forEach((f: File) => fd.append('attachments', f)); - const token = getToken(); - const headers: HeadersInit = {}; - if (token) headers['Authorization'] = `Bearer ${token}`; - const response = await fetch(url, { method, headers, body: fd }); - if (!response.ok) { - const details = await extractErrorDetails(response); - console.error('API Error Response:', details); - throw new Error(`API Error: ${response.status} ${details}`); - } - return response.json(); + return request(url, { method, body: fd, timeoutMs: 120_000 }); } return { @@ -80,16 +50,11 @@ export function createFeedApi(request: RequestFn, getToken: GetTokenFn) { const fd = new FormData(); fd.append('text', text); if (image) fd.append('image', image); if (attachment) fd.append('attachment', attachment); - const token = getToken(); - const headers: HeadersInit = {}; - if (token) headers['Authorization'] = `Bearer ${token}`; - const response = await fetch(`/api/v1/posts/${postId}/comments/`, { method: 'POST', headers, body: fd }); - if (!response.ok) { - const details = await extractErrorDetails(response); - console.error('API Error Response:', details); - throw new Error(`API Error: ${response.status} ${details}`); - } - return response.json(); + return request(`/api/v1/posts/${postId}/comments/`, { + method: 'POST', + body: fd, + timeoutMs: 120_000, + }); } return request(`/api/v1/posts/${postId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }); }, diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index 3f5bcc7f..faf12016 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -20,21 +20,20 @@ import { createFinanceApi } from './finance'; const base = new ApiClientBase(); const req = base.request.bind(base); -const tok = base.getToken.bind(base); const raw = base.requestRaw.bind(base); export const apiClient = Object.assign(base, { ...createAuthApi(req), ...createEmployeesApi(req), - ...createDocumentsApi(req, tok, raw), - ...createMessagesApi(req, tok), + ...createDocumentsApi(req, raw), + ...createMessagesApi(req), ...createNotificationsApi(req), - ...createCalendarApi(req, tok), - ...createFeedApi(req, tok), - ...createRequestsApi(req, tok), - ...createEquipmentApi(req, tok), + ...createCalendarApi(req, raw), + ...createFeedApi(req), + ...createRequestsApi(req), + ...createEquipmentApi(req), ...createProcurementApi(req, raw), - ...createAttendanceApi(req, tok), + ...createAttendanceApi(req, raw), ...createGuestsApi(req), ...createTasksApi(req, raw), ...createFinanceApi(req), diff --git a/frontend/src/lib/api/messages.ts b/frontend/src/lib/api/messages.ts index 087e54c1..776f2ea9 100644 --- a/frontend/src/lib/api/messages.ts +++ b/frontend/src/lib/api/messages.ts @@ -1,23 +1,19 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { ChatMessageSearchResponse } from '@/types/api'; -import type { RequestFn, GetTokenFn } from './utils'; +import type { RequestFn } from './utils'; -export function createMessagesApi(request: RequestFn, getToken: GetTokenFn) { +export function createMessagesApi(request: RequestFn) { async function uploadMessage(chatId: number | string, text: string, files: File[], replyTo?: number) { const fd = new FormData(); fd.append('content', text); fd.append('chat_id', chatId.toString()); if (replyTo) fd.append('reply_to', replyTo.toString()); files.forEach((file, i) => fd.append(`file_${i}`, file)); - const token = getToken(); - const headers: Record = {}; - if (token) headers['Authorization'] = `Bearer ${token}`; - const response = await fetch('/api/v1/communications/messages/upload/', { method: 'POST', headers, body: fd }); - if (!response.ok) { - let d = ''; try { const e = await response.json(); d = e.detail || JSON.stringify(e); } catch { d = response.statusText; } - throw new Error(`API Error: ${response.status} ${d}`); - } - const result = await response.json(); + const result = await request('/api/v1/communications/messages/upload/', { + method: 'POST', + body: fd, + timeoutMs: 120_000, + }); return result.message || result; } diff --git a/frontend/src/lib/api/requests.ts b/frontend/src/lib/api/requests.ts index 11b66cf9..0ad67be2 100644 --- a/frontend/src/lib/api/requests.ts +++ b/frontend/src/lib/api/requests.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { RequestEmployeeStatistics, User } from '@/types/api'; -import type { RequestFn, GetTokenFn } from './utils'; +import type { RequestFn } from './utils'; -export function createRequestsApi(request: RequestFn, getToken: GetTokenFn) { +export function createRequestsApi(request: RequestFn) { async function formDataRequest(url: string, method: string, data: Record, saveAs?: 'draft' | 'submit') { const fd = new FormData(); Object.keys(data).forEach((key) => { @@ -12,12 +12,7 @@ export function createRequestsApi(request: RequestFn, getToken: GetTokenFn) { else if (data[key] !== null && data[key] !== undefined) { fd.append(key, String(data[key])); } }); if (saveAs) fd.append('save_as', saveAs); - const token = getToken(); - const headers: Record = {}; - if (token) headers['Authorization'] = `Bearer ${token}`; - const response = await fetch(url, { method, headers, body: fd }); - if (!response.ok) { let d = `${response.status} ${response.statusText}`; try { d = JSON.stringify(await response.json()); } catch {} throw new Error(d); } - return response.json(); + return request(url, { method, body: fd, timeoutMs: 120_000 }); } return { diff --git a/frontend/src/lib/api/utils.ts b/frontend/src/lib/api/utils.ts index d9c4e972..966859f6 100644 --- a/frontend/src/lib/api/utils.ts +++ b/frontend/src/lib/api/utils.ts @@ -1,3 +1,5 @@ +import type { ApiRequestOptions } from './client'; + /** Helper to build URL query string from params object */ export function buildQuery(params?: Record): string { if (!params) return ''; @@ -12,6 +14,6 @@ export function buildQuery(params?: Record Promise; -export type RawRequestFn = (endpoint: string, options?: RequestInit) => Promise; +export type RequestFn = (endpoint: string, options?: ApiRequestOptions) => Promise; +export type RawRequestFn = (endpoint: string, options?: ApiRequestOptions) => Promise; export type GetTokenFn = () => string | null; diff --git a/frontend/src/lib/websocketManager.ts b/frontend/src/lib/websocketManager.ts index 1ddd70c3..9e5c4f97 100644 --- a/frontend/src/lib/websocketManager.ts +++ b/frontend/src/lib/websocketManager.ts @@ -3,6 +3,8 @@ * Управляет единственным WebSocket соединением для всего приложения */ +import { AUTH_SESSION_EVENT, type AuthSessionEventDetail } from '@/lib/api/client'; + type RealtimeEvent = { type: string; [key: string]: unknown; @@ -27,7 +29,18 @@ class WebSocketManager { private isConnecting = false; private shouldReconnect = false; - private constructor() {} + private constructor() { + if (typeof window !== 'undefined') { + window.addEventListener(AUTH_SESSION_EVENT, (event) => { + const detail = (event as CustomEvent).detail; + if (detail?.type === 'token-refreshed') this.reconnectWithLatestToken(); + if (detail?.type === 'session-cleared') this.disconnect(); + }); + window.addEventListener('storage', (event) => { + if (event.key === 'access_token') this.reconnectWithLatestToken(); + }); + } + } static getInstance(): WebSocketManager { if (!WebSocketManager.instance) { @@ -80,6 +93,29 @@ class WebSocketManager { }); } + private reconnectWithLatestToken() { + if (!this.hasSubscribers() || typeof window === 'undefined') return; + if (!localStorage.getItem('access_token')) { + this.disconnect(); + return; + } + + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + const previousSocket = this.ws; + this.ws = null; + if (previousSocket) { + previousSocket.onclose = null; + previousSocket.close(); + } + this.isConnecting = false; + this.reconnectAttempts = 0; + this.shouldReconnect = true; + this.connect(); + } + connect() { // Если уже подключены или подключаемся, не создаем новое соединение if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) { @@ -122,7 +158,6 @@ class WebSocketManager { console.log('[WS Manager] Backend URL:', backendUrl); console.log('[WS Manager] Protocol:', protocol); console.log('[WS Manager] Host:', host); - console.log('[WS Manager] Full WS URL:', wsUrl); console.log('[WS Manager] Has token:', !!token); this.ws = new WebSocket(wsUrl); From 3643bea2fc1b1b248cc88d05792535cc485282e7 Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 6 Aug 2026 09:52:17 +0300 Subject: [PATCH 2/2] fix chat workspace scrolling and layout --- frontend/src/app/messages/[chatId]/page.tsx | 107 +++++++----------- frontend/src/app/tasks/page.tsx | 2 +- .../src/components/ScrollableMessageList.tsx | 15 ++- .../components/messages/ChatMessageItem.tsx | 12 +- .../messages/MessagesFocusSidebar.tsx | 2 +- 5 files changed, 66 insertions(+), 72 deletions(-) diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 4149e686..5d3bf140 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -1328,29 +1328,6 @@ function MessageDialogPageContent() { return () => vv.removeEventListener("resize", onResize); }, []); - // Redirect wheel events outside chat container into it - useEffect(() => { - const onWheel = (e: WheelEvent) => { - if (e.defaultPrevented || mediaPreview) return; - const c = messagesViewportRef.current; - if (!c?.containerRef?.current) return; - const v = c.containerRef.current; - const t = e.target as HTMLElement | null; - if ( - !t || - v.contains(t) || - t.closest("[data-overlay-root='true'], [role='dialog'], [aria-modal='true']") || - t.closest("textarea,input,button,a,video,audio,[contenteditable='true']") - ) { - return; - } - e.preventDefault(); - v.scrollTop += e.deltaY; - }; - window.addEventListener("wheel", onWheel, { passive: false }); - return () => window.removeEventListener("wheel", onWheel); - }, [mediaPreview]); - /* ────────────────────────────────────────────────────── * RENDER * ────────────────────────────────────────────────────── */ @@ -1445,7 +1422,7 @@ function MessageDialogPageContent() { ) : displayMessages.length === 0 ? (

Пока нет сообщений. Напишите первым.

) : ( -
+
{cm.loadingOlder ?

Подгружаем старые сообщения...

: null} {displayMessages.map((message, index) => { const replyToId = getReplyToId(message); @@ -1515,11 +1492,11 @@ function MessageDialogPageContent() { {/* Scroll-to-bottom button */} {scroll.showScrollToBottom && ( -
+