Merge branch 'develop'
This commit is contained in:
commit
7ab5de99d9
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -306,30 +306,16 @@ async function downloadMonthlyAttendanceMatrix(
|
||||
downloadMonthlyAttendanceMatrix?: (
|
||||
params: AttendanceMatrixExportQuery,
|
||||
) => Promise<AttendanceMatrixExportFile>;
|
||||
getToken?: () => string | null;
|
||||
};
|
||||
|
||||
if (typeof client.downloadMonthlyAttendanceMatrix === "function") {
|
||||
return client.downloadMonthlyAttendanceMatrix(params);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
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`;
|
||||
|
||||
@ -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 ? (
|
||||
<p className="app-text-muted text-center text-sm">Пока нет сообщений. Напишите первым.</p>
|
||||
) : (
|
||||
<div className="flex min-h-full flex-col justify-end">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col justify-end">
|
||||
{cm.loadingOlder ? <p className="app-text-muted mb-3 text-center text-xs">Подгружаем старые сообщения...</p> : null}
|
||||
{displayMessages.map((message, index) => {
|
||||
const replyToId = getReplyToId(message);
|
||||
@ -1515,11 +1492,11 @@ function MessageDialogPageContent() {
|
||||
|
||||
{/* Scroll-to-bottom button */}
|
||||
{scroll.showScrollToBottom && (
|
||||
<div className="pointer-events-auto absolute bottom-25 right-3 z-20 transition-opacity duration-300">
|
||||
<div className="pointer-events-none absolute bottom-25 left-1/2 z-20 flex w-full max-w-[64rem] -translate-x-1/2 justify-end px-3 transition-opacity duration-300">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleReturnToNewMessages()}
|
||||
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full border border-[var(--border-subtle)] bg-[var(--surface-primary)] p-0 leading-none text-[var(--muted-foreground)] shadow-[0_10px_30px_rgba(15,23,42,0.22)] transition hover:bg-[var(--surface-secondary)] hover:text-[var(--foreground)] active:scale-[0.98]"
|
||||
className="pointer-events-auto relative inline-flex h-10 w-10 items-center justify-center rounded-full border border-[var(--border-subtle)] bg-[var(--surface-primary)] p-0 leading-none text-[var(--muted-foreground)] shadow-[0_10px_30px_rgba(15,23,42,0.22)] transition hover:bg-[var(--surface-secondary)] hover:text-[var(--foreground)] active:scale-[0.98]"
|
||||
title="Вернуться к новым сообщениям"
|
||||
aria-label={displayedUnreadCount > 0 ? `Вернуться к новым сообщениям (${displayedUnreadCount > 99 ? "99+" : displayedUnreadCount})` : "Вернуться к новым сообщениям"}
|
||||
>
|
||||
@ -1535,46 +1512,48 @@ function MessageDialogPageContent() {
|
||||
|
||||
{/* Composer area */}
|
||||
<div className="app-divider shrink-0 border-t bg-transparent px-4 pt-3 pb-[calc(env(safe-area-inset-bottom)+0.25rem)] lg:app-header lg:px-0 lg:pb-0">
|
||||
{typingIndicatorText && (
|
||||
<div className="app-text-muted mb-2 flex items-center gap-2 text-xs italic">
|
||||
<div className="flex gap-1">
|
||||
<span className="app-text-muted inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-current" style={{ animationDelay: "0ms" }} />
|
||||
<span className="app-text-muted inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-current" style={{ animationDelay: "150ms" }} />
|
||||
<span className="app-text-muted inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-current" style={{ animationDelay: "300ms" }} />
|
||||
<div className="mx-auto w-full max-w-[64rem]">
|
||||
{typingIndicatorText && (
|
||||
<div className="app-text-muted mb-2 flex items-center gap-2 text-xs italic">
|
||||
<div className="flex gap-1">
|
||||
<span className="app-text-muted inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-current" style={{ animationDelay: "0ms" }} />
|
||||
<span className="app-text-muted inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-current" style={{ animationDelay: "150ms" }} />
|
||||
<span className="app-text-muted inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-current" style={{ animationDelay: "300ms" }} />
|
||||
</div>
|
||||
<span>{typingIndicatorText}</span>
|
||||
</div>
|
||||
<span>{typingIndicatorText}</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
<MessageComposer
|
||||
canSendMessages={canSendMessages}
|
||||
membershipRole={myMembership?.role}
|
||||
editingMessageId={editingMessageId}
|
||||
replyTo={replyTo}
|
||||
attachedFiles={attachedFiles}
|
||||
messageText={messageText}
|
||||
sending={sendingComposer}
|
||||
showEmojiPicker={showComposerEmojiPicker}
|
||||
allReactions={ALL_REACTIONS}
|
||||
fileInputRef={fileInputRef}
|
||||
messageInputRef={messageInputRef}
|
||||
onPickFiles={handlePickFiles}
|
||||
onAddFiles={addAttachedFiles}
|
||||
onFilesChange={handleFilesChange}
|
||||
onRemoveFile={removeAttachedFile}
|
||||
onToggleEmojiPicker={() => {
|
||||
setShowComposerEmojiPicker(prev => !prev);
|
||||
setExpandedReplyActionForId(null);
|
||||
setReactionPickerForMessageId(null);
|
||||
}}
|
||||
onSelectEmoji={(emoji) => { appendEmojiToComposer(emoji); setShowComposerEmojiPicker(false); }}
|
||||
onInputClick={() => setShowComposerEmojiPicker(false)}
|
||||
onChangeMessage={setMessageText}
|
||||
onTyping={sendTyping}
|
||||
onSend={handleSend}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
/>
|
||||
<MessageComposer
|
||||
canSendMessages={canSendMessages}
|
||||
membershipRole={myMembership?.role}
|
||||
editingMessageId={editingMessageId}
|
||||
replyTo={replyTo}
|
||||
attachedFiles={attachedFiles}
|
||||
messageText={messageText}
|
||||
sending={sendingComposer}
|
||||
showEmojiPicker={showComposerEmojiPicker}
|
||||
allReactions={ALL_REACTIONS}
|
||||
fileInputRef={fileInputRef}
|
||||
messageInputRef={messageInputRef}
|
||||
onPickFiles={handlePickFiles}
|
||||
onAddFiles={addAttachedFiles}
|
||||
onFilesChange={handleFilesChange}
|
||||
onRemoveFile={removeAttachedFile}
|
||||
onToggleEmojiPicker={() => {
|
||||
setShowComposerEmojiPicker(prev => !prev);
|
||||
setExpandedReplyActionForId(null);
|
||||
setReactionPickerForMessageId(null);
|
||||
}}
|
||||
onSelectEmoji={(emoji) => { appendEmojiToComposer(emoji); setShowComposerEmojiPicker(false); }}
|
||||
onInputClick={() => setShowComposerEmojiPicker(false)}
|
||||
onChangeMessage={setMessageText}
|
||||
onTyping={sendTyping}
|
||||
onSend={handleSend}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -5598,7 +5598,7 @@ function TasksPageContent() {
|
||||
className={`${mobileBoardOpen ? "hidden" : "flex"} h-[calc(100dvh-7rem)] w-full ${desktopWideMode ? "lg:flex lg:h-full lg:shrink-0" : ""}`}
|
||||
/>
|
||||
|
||||
<div className={`${mobileBoardOpen ? "flex" : "hidden"} relative min-h-0 min-w-0 flex-1 flex-col gap-4 ${desktopWideMode ? "lg:flex lg:h-full" : "xl:flex xl:h-full"}`}>
|
||||
<div className={`${mobileBoardOpen ? "flex" : "hidden"} relative min-h-0 min-w-0 flex-1 flex-col gap-4 ${desktopWideMode ? "lg:flex lg:h-full" : ""}`}>
|
||||
<section className="app-surface relative z-30 shrink-0 overflow-visible rounded-2xl p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
|
||||
@ -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 (
|
||||
<div className="app-shell min-h-screen flex items-center justify-center p-4">
|
||||
<div className="app-surface w-full max-w-md rounded-lg p-5 text-center">
|
||||
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-sky-400" />
|
||||
<h1 className="text-base font-semibold">Восстанавливаем соединение</h1>
|
||||
<p className="app-text-muted mt-2 text-sm">{authError || 'Сервер временно недоступен.'}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button-primary mt-4 px-4 py-2 text-sm"
|
||||
onClick={() => void retryAuthentication()}
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Если пользователь не авторизован, ничего не показываем (уже редиректим)
|
||||
if (!user) {
|
||||
return null;
|
||||
|
||||
@ -155,7 +155,11 @@ export class ScrollableMessageListInner extends Component<ScrollableMessageListP
|
||||
/**
|
||||
* Восстанавливает позицию скролла ПОСЛЕ обновления React
|
||||
*/
|
||||
componentDidUpdate(_prevProps: ScrollableMessageListProps, _prevState: any, snapshot: Snapshot | null) {
|
||||
componentDidUpdate(
|
||||
_prevProps: ScrollableMessageListProps,
|
||||
_prevState: Readonly<Record<string, never>>,
|
||||
snapshot: Snapshot | null,
|
||||
) {
|
||||
if (!snapshot) return;
|
||||
|
||||
const container = this.containerRef.current;
|
||||
@ -337,8 +341,10 @@ export class ScrollableMessageListInner extends Component<ScrollableMessageListP
|
||||
const container = this.containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Принудительно ставим scrollTop на максимум — самый надёжный способ
|
||||
container.scrollTop = container.scrollHeight;
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: scrollBehavior === 'smooth' ? 'auto' : scrollBehavior,
|
||||
});
|
||||
|
||||
this.lastClientHeight = container.clientHeight;
|
||||
this.noScroll = true;
|
||||
@ -369,6 +375,7 @@ export class ScrollableMessageListInner extends Component<ScrollableMessageListP
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
overscrollBehaviorY: 'contain',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
@ -384,7 +391,7 @@ export class ScrollableMessageListInner extends Component<ScrollableMessageListP
|
||||
// ForwardRef обертка для использования с useRef в функциональных компонентах
|
||||
export const ScrollableMessageList = forwardRef<ScrollableMessageListInner, ScrollableMessageListProps>(
|
||||
(props, ref) => {
|
||||
return <ScrollableMessageListInner ref={ref as any} {...props} />;
|
||||
return <ScrollableMessageListInner ref={ref} {...props} />;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@ -49,27 +49,13 @@ function getAttendanceRecordDayEvents(recordId: number) {
|
||||
async function getAttendanceDayEventPhoto(photoUrl: string) {
|
||||
const client = apiClient as typeof apiClient & {
|
||||
getAttendanceDayEventPhoto?: (photoUrl: string) => Promise<Blob>;
|
||||
getToken?: () => string | null;
|
||||
};
|
||||
|
||||
if (typeof client.getAttendanceDayEventPhoto === "function") {
|
||||
return client.getAttendanceDayEventPhoto(photoUrl);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@ -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<string, string> {
|
||||
if (typeof window === "undefined") return {};
|
||||
const token = window.localStorage.getItem("access_token");
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function fetchFile(fileUrl: string): Promise<ArrayBuffer> {
|
||||
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<ExcelSheet[]>([]);
|
||||
const [activeSheet, setActiveSheet] = useState(0);
|
||||
const [textContent, setTextContent] = useState("");
|
||||
const [pdfData, setPdfData] = useState<Uint8Array | null>(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 && <LoadingState label="Загрузка PDF..." />}
|
||||
{error && <ErrorState message={error} fileUrl={fileUrl} />}
|
||||
<div className="flex justify-center">
|
||||
<PDFDocument
|
||||
{pdfSource && <PDFDocument
|
||||
file={pdfSource}
|
||||
onLoadSuccess={({ numPages: loadedPages }) => {
|
||||
setNumPages(loadedPages);
|
||||
@ -346,7 +351,7 @@ export function DocumentPreviewPane({ fileUrl, fileName, className = "" }: Docum
|
||||
renderTextLayer
|
||||
className="overflow-hidden rounded-lg shadow-lg"
|
||||
/>
|
||||
</PDFDocument>
|
||||
</PDFDocument>}
|
||||
</div>
|
||||
</div>
|
||||
{numPages > 0 && (
|
||||
|
||||
@ -223,7 +223,13 @@ export default function ChatMessageItem({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={`flex min-w-0 items-start gap-1 ${isMine ? "max-w-[88%] flex-row-reverse" : "max-w-[calc(100%-2.5rem)]"}`}>
|
||||
<div
|
||||
className={`flex w-fit min-w-0 items-start gap-1 sm:max-w-[min(78%,46rem)] ${
|
||||
isMine
|
||||
? "max-w-[88%] flex-row-reverse"
|
||||
: "max-w-[calc(100%-2.5rem)]"
|
||||
}`}
|
||||
>
|
||||
<div className={`relative min-w-0 rounded-2xl px-3 py-2 pr-9 transition-shadow duration-300 ${isMine ? "bg-sky-500 text-white" : "app-surface-elevated text-[var(--foreground)]"} ${isHighlighted ? (isMine ? "shadow-[0_0_0_3px_rgba(125,211,252,0.45)]" : "ring-2 ring-sky-300 shadow-[0_0_0_4px_rgba(186,230,253,0.28)]") : ""}`}>
|
||||
{hasActions ? (
|
||||
<div className="absolute right-1 top-1 z-20">
|
||||
@ -296,7 +302,9 @@ export default function ChatMessageItem({
|
||||
{message.is_deleted ? (
|
||||
<p className={`italic text-sm ${isMine ? "text-sky-100" : "app-text-muted"}`}>Сообщение удалено</p>
|
||||
) : message.content ? (
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-5">{message.content}</p>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-5 [overflow-wrap:anywhere]">
|
||||
{message.content}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{message.attachments && message.attachments.length > 0 ? (
|
||||
|
||||
@ -114,7 +114,7 @@ export default function MessagesFocusSidebar({
|
||||
<MessagesChatListControls userId={user?.id} chatTypeCounts={chatTypeCounts} />
|
||||
</div> : null}
|
||||
|
||||
<div className={`min-h-0 flex-1 space-y-2 overflow-y-auto pb-4 ${sidebarCollapsed ? "px-2 pt-2" : "px-4"}`}>
|
||||
<div className={`min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-y-contain pb-4 ${sidebarCollapsed ? "px-2 pt-2" : "px-4"}`}>
|
||||
{loading ? (
|
||||
<p className="app-text-muted px-3 py-6 text-center text-xs">Загрузка чатов...</p>
|
||||
) : visibleChats.length === 0 ? (
|
||||
|
||||
@ -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<void>;
|
||||
retryAuthentication: () => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const UserContext = createContext<UserContextType | undefined>(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<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState<AuthStatus>('loading');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const retryTimerRef = useRef<number | null>(null);
|
||||
const retryAttemptRef = useRef(0);
|
||||
const loadUserRef = useRef<() => Promise<void>>(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<AuthSessionEventDetail>).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;
|
||||
}
|
||||
|
||||
@ -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<string, { count: number; users?: number[]; user_names?: string[] }>;
|
||||
|
||||
@ -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<AuthSessionEventDetail>).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 () => {
|
||||
|
||||
@ -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<AttendanceMatrixExportFile> => {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {};
|
||||
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<AttendanceDayEvent[]>,
|
||||
getAttendanceDayEventPhoto: async (photoUrl: string): Promise<Blob> => {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {};
|
||||
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) =>
|
||||
|
||||
@ -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> | Calendar[];
|
||||
type CalendarEventsResponse = PaginatedResponse<CalendarEvent> | CalendarEvent[];
|
||||
type CalendarRulesParams = Record<string, unknown>;
|
||||
|
||||
export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
export function createCalendarApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
return {
|
||||
getCalendarEvents: (params?: CalendarQueryParams): Promise<CalendarEventsResponse> => {
|
||||
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<Blob> => {
|
||||
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 }) =>
|
||||
|
||||
167
frontend/src/lib/api/client.test.js
Normal file
167
frontend/src/lib/api/client.test.js
Normal file
@ -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');
|
||||
});
|
||||
@ -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<string> | 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<AuthSessionEventDetail>(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<Response> {
|
||||
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<string> {
|
||||
private async performRefresh(): Promise<string> {
|
||||
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<string, string> {
|
||||
refreshSession(): Promise<string> {
|
||||
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<string> = typeof navigator !== 'undefined' && navigator.locks
|
||||
? navigator.locks.request('eusrr-auth-refresh', runRefresh) as unknown as Promise<string>
|
||||
: runRefresh();
|
||||
|
||||
const pendingRefresh = refresh.finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
this.refreshPromise = pendingRefresh;
|
||||
return pendingRefresh;
|
||||
}
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
private buildHeaders(options: RequestInit, token = this.getToken()): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
@ -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<Response> {
|
||||
const headers = this.buildHeaders(options);
|
||||
const response = await fetch(endpoint, { ...options, headers });
|
||||
private async execute(endpoint: string, options: ApiRequestOptions): Promise<Response> {
|
||||
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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
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<Response> {
|
||||
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<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||
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<T>(endpoint: string, options: ApiRequestOptions): Promise<T> {
|
||||
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<void> {
|
||||
const response = await fetch('/api/auth/token/', {
|
||||
const data = await this.unauthenticatedRequest<LoginResponse>('/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<void> {
|
||||
const response = await fetch('/api/auth/qr-login/exchange/', {
|
||||
const data = await this.unauthenticatedRequest<LoginResponse>('/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<QrLoginRequestCreateResult> {
|
||||
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<QrLoginRequestStatusResult> {
|
||||
const response = await fetch('/api/auth/qr-login/requests/status/', {
|
||||
const data = await this.unauthenticatedRequest<QrLoginRequestStatusResult>('/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<void> {
|
||||
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<string, any>): Promise<any> {
|
||||
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<string, string> = {};
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string, string> = {
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
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/`),
|
||||
|
||||
@ -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<string, string | number>) => request(`/api/v1/procurement/equipment-categories/${buildQuery(params)}`),
|
||||
createEquipmentCategory: (data: { name: string; parent?: number | null; description?: string; icon?: string }) =>
|
||||
|
||||
@ -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<string> {
|
||||
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<string, any>) {
|
||||
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 }) });
|
||||
},
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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<string, string> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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<string, any>, 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<string, string> = {};
|
||||
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 {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { ApiRequestOptions } from './client';
|
||||
|
||||
/** Helper to build URL query string from params object */
|
||||
export function buildQuery(params?: Record<string, string | number | boolean | undefined | null>): string {
|
||||
if (!params) return '';
|
||||
@ -12,6 +14,6 @@ export function buildQuery(params?: Record<string, string | number | boolean | u
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type RequestFn = (endpoint: string, options?: RequestInit) => Promise<any>;
|
||||
export type RawRequestFn = (endpoint: string, options?: RequestInit) => Promise<Response>;
|
||||
export type RequestFn = (endpoint: string, options?: ApiRequestOptions) => Promise<any>;
|
||||
export type RawRequestFn = (endpoint: string, options?: ApiRequestOptions) => Promise<Response>;
|
||||
export type GetTokenFn = () => string | null;
|
||||
|
||||
@ -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<AuthSessionEventDetail>).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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user