Merge branch 'develop'

This commit is contained in:
lizerk 2026-08-06 09:52:30 +03:00
commit 7ab5de99d9
29 changed files with 879 additions and 493 deletions

View File

@ -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(

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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);
}
}

View File

@ -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`;

View File

@ -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>
</>

View File

@ -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">

View File

@ -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;

View File

@ -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} />;
}
);

View File

@ -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();
}

View File

@ -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 && (

View File

@ -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 ? (

View File

@ -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 ? (

View File

@ -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;
}

View File

@ -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 () => {

View File

@ -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) =>

View File

@ -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 }) =>

View 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');
});

View File

@ -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,
});
}
}

View File

@ -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/`),

View File

@ -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 }) =>

View File

@ -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 }) });
},

View File

@ -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),

View File

@ -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;
}

View File

@ -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 {

View File

@ -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;

View File

@ -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);