/** * Base API client: authentication state, token refresh and HTTP transport. */ import type { LoginResponse, QrLoginRequestCreateResult, QrLoginRequestStatusResult, SearchModelType, SearchResponse, } from "@/types/api"; interface LoginCredentials { login?: string; email?: string; phone?: string; password: string; } interface BrowserSessionResponse { redirect_url: 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 readonly tokenKey = 'access_token'; private readonly refreshTokenKey = 'refresh_token'; private refreshPromise: Promise | null = null; getToken(): string | null { if (typeof window === 'undefined') return null; return localStorage.getItem(this.tokenKey); } getRefreshToken(): string | null { if (typeof window === 'undefined') return null; return localStorage.getItem(this.refreshTokenKey); } hasStoredSession(): boolean { return Boolean(this.getToken() || this.getRefreshToken()); } getAccessTokenExpiresAt(): number | null { const token = this.getToken(); if (!token) return null; try { const payload = token.split('.')[1]; if (!payload) return null; const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); const decoded = JSON.parse(atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='))); return typeof decoded.exp === 'number' ? decoded.exp * 1000 : null; } catch { return null; } } private emitAuthEvent(detail: AuthSessionEventDetail): void { if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(AUTH_SESSION_EVENT, { detail })); } } private setToken(token: string): void { if (typeof window === 'undefined') return; localStorage.setItem(this.tokenKey, token); this.emitAuthEvent({ type: 'token-refreshed' }); } private setRefreshToken(token: string): void { if (typeof window !== 'undefined') { localStorage.setItem(this.refreshTokenKey, token); } } clearToken(reason: 'expired' | 'logout' = 'logout'): void { if (typeof window === 'undefined') return; localStorage.removeItem(this.tokenKey); localStorage.removeItem(this.refreshTokenKey); this.emitAuthEvent({ type: 'session-cleared', reason }); } private recoveryError(message?: string, cause?: unknown): AuthRecoveryError { const error = new AuthRecoveryError(message, cause === undefined ? undefined : { cause }); this.emitAuthEvent({ type: 'recovery-required', message: error.message }); return error; } private async fetchWithTimeout( endpoint: string, options: ApiRequestOptions = {}, fallbackTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, ): Promise { const { timeoutMs = fallbackTimeoutMs, signal, ...requestOptions } = options; const controller = new AbortController(); const abortFromCaller = () => controller.abort(signal?.reason); if (signal?.aborted) { abortFromCaller(); } else { signal?.addEventListener('abort', abortFromCaller, { once: true }); } const timeoutId = window.setTimeout(() => controller.abort('timeout'), timeoutMs); try { return await fetch(endpoint, { ...requestOptions, signal: controller.signal }); } catch (error) { if (signal?.aborted) throw error; const message = controller.signal.aborted ? 'Превышено время ожидания ответа сервера.' : 'Не удалось соединиться с сервером.'; throw new ApiNetworkError(message, { cause: error }); } finally { window.clearTimeout(timeoutId); signal?.removeEventListener('abort', abortFromCaller); } } private async performRefresh(): Promise { const refreshToken = this.getRefreshToken(); if (!refreshToken) { this.clearToken('expired'); throw new AuthSessionExpiredError(); } 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) { throw this.recoveryError(`Сервер временно не может обновить сессию (HTTP ${response.status}).`); } 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; } refreshSession(): Promise { if (!this.refreshPromise) { const accessBeforeRefresh = this.getToken(); const runRefresh = async () => { const currentAccess = this.getToken(); if (currentAccess && currentAccess !== accessBeforeRefresh) { return currentAccess; } return this.performRefresh(); }; const refresh: Promise = typeof navigator !== 'undefined' && navigator.locks ? navigator.locks.request('eusrr-auth-refresh', runRefresh) as unknown as Promise : runRefresh(); const pendingRefresh = refresh.finally(() => { this.refreshPromise = null; }); this.refreshPromise = pendingRefresh; return pendingRefresh; } return this.refreshPromise; } private buildHeaders(options: RequestInit, token = this.getToken()): Record { const headers: Record = { ...(options.headers as Record), }; if (!(options.body instanceof FormData) && !headers['Content-Type']) { headers['Content-Type'] = 'application/json'; } if (token) headers.Authorization = `Bearer ${token}`; return headers; } private async parseErrorDetails(response: Response): Promise { try { const data = await response.json(); return data.detail || JSON.stringify(data); } catch { return response.statusText; } } private async execute(endpoint: string, options: ApiRequestOptions): Promise { let response = await this.fetchWithTimeout(endpoint, { ...options, headers: this.buildHeaders(options), }); if (response.status === 401 && this.getRefreshToken()) { const newToken = await this.refreshSession(); response = await this.fetchWithTimeout(endpoint, { ...options, headers: this.buildHeaders(options, newToken), }); } return response; } async requestRaw(endpoint: string, options: ApiRequestOptions = {}): Promise { const response = await this.execute(endpoint, options); if (!response.ok) { const details = await this.parseErrorDetails(response); throw new ApiError(response.status, `API Error: ${response.status} ${details}`); } return response; } async request(endpoint: string, options: ApiRequestOptions = {}): Promise { const response = await this.requestRaw(endpoint, options); if (response.status === 204 || response.headers.get('content-length') === '0') { return undefined as T; } return response.json(); } private async unauthenticatedRequest(endpoint: string, options: ApiRequestOptions): Promise { const response = await this.fetchWithTimeout(endpoint, options, AUTH_REQUEST_TIMEOUT_MS); if (!response.ok) { const details = await this.parseErrorDetails(response); throw new ApiError(response.status, `API Error: ${response.status} ${details}`); } if (response.status === 204) return undefined as T; return response.json(); } // Auth async login(credentials: LoginCredentials): Promise { const data = await this.unauthenticatedRequest('/api/auth/token/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), }); this.setRefreshToken(data.refresh); this.setToken(data.access); } async establishBrowserSession(nextUrl: string): Promise { const data = await this.request('/api/auth/browser-session/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ next: nextUrl }), }); return data.redirect_url; } async logoutBrowserSession(): Promise { // This endpoint deliberately does not depend on JWT authentication: // the browser SSO cookie must still be cleared when access/refresh // tokens have expired or were removed by another tab. await this.unauthenticatedRequest('/api/auth/browser-session/logout/', { method: 'POST', credentials: 'include', }); } async loginWithQrToken(token: string): Promise { const data = await this.unauthenticatedRequest('/api/auth/qr-login/exchange/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), }); this.setRefreshToken(data.refresh); this.setToken(data.access); } async createQrLoginRequest(): Promise { return this.unauthenticatedRequest('/api/auth/qr-login/requests/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); } async pollQrLoginRequest(clientSecret: string): Promise { const data = await this.unauthenticatedRequest('/api/auth/qr-login/requests/status/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_secret: clientSecret }), }); if (data.status === 'approved' && data.access && data.refresh) { this.setRefreshToken(data.refresh); this.setToken(data.access); } return data; } async cancelQrLoginRequest(clientSecret: string): Promise { await this.unauthenticatedRequest('/api/auth/qr-login/requests/cancel/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_secret: clientSecret }), }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async getCurrentUser(): Promise { return this.request('/api/v1/employees/me/'); } async search(query: string, options: { limit?: number; offset?: number; modelType?: SearchModelType; } = {}): Promise { const queryParams = new URLSearchParams(); queryParams.append('q', query); if (options.limit) queryParams.append('limit', options.limit.toString()); if (options.offset) queryParams.append('offset', options.offset.toString()); if (options.modelType) queryParams.append('type', options.modelType); return this.request(`/api/v1/search/?${queryParams.toString()}`); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async updateCurrentUserProfile(data: Record): Promise { const hasFile = Object.values(data).some((value) => value instanceof File); if (!hasFile) { return this.request('/api/v1/employees/me/', { method: 'PATCH', body: JSON.stringify(data), }); } const formData = new FormData(); Object.keys(data).forEach((key) => { if (Array.isArray(data[key])) { 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]); } }); return this.request('/api/v1/employees/me/', { method: 'PATCH', body: formData, timeoutMs: 120_000, }); } }