Files
bedolaga-cabinet/src/api/client.ts
Fringg 83aeae81b8 feat: add OAuth 2.0 login UI (Google, Yandex, Discord, VK)
- Add OAuthProvider type and extend User.auth_type union
- Add OAuth API methods (providers, authorize, callback)
- Add loginWithOAuth to auth store
- Create OAuthCallback page with state validation
- Create OAuthProviderIcon component with brand SVGs
- Add OAuth buttons to Login page between Telegram and Email
- Add OAuth callback route to App.tsx
- Add translations for ru, en, zh, fa
2026-02-07 01:59:05 +03:00

241 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { retrieveRawInitData } from '@telegram-apps/sdk-react';
import {
tokenStorage,
isTokenExpired,
tokenRefreshManager,
safeRedirectToLogin,
} from '../utils/token';
import { useBlockingStore } from '../store/blocking';
import { API } from '../config/constants';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
// Настраиваем endpoint для refresh
tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`);
// CSRF token management
const CSRF_COOKIE_NAME = 'csrf_token';
const CSRF_HEADER_NAME = 'X-CSRF-Token';
function getCsrfToken(): string | null {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`));
return match ? match[2] : null;
}
function generateCsrfToken(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
function ensureCsrfToken(): string {
let token = getCsrfToken();
if (!token) {
token = generateCsrfToken();
// Set cookie with SameSite=Strict for CSRF protection
document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure`;
}
return token;
}
const getTelegramInitData = (): string | null => {
if (typeof window === 'undefined') return null;
try {
const raw = retrieveRawInitData();
if (raw) {
tokenStorage.setTelegramInitData(raw);
return raw;
}
} catch {
// Not in Telegram or SDK not initialized
}
return tokenStorage.getTelegramInitData();
};
export const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: API.TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
},
});
// Auth endpoints that don't need Bearer token or token refresh
const AUTH_ENDPOINTS = [
'/cabinet/auth/telegram',
'/cabinet/auth/telegram/widget',
'/cabinet/auth/email/login',
'/cabinet/auth/email/register',
'/cabinet/auth/email/verify',
'/cabinet/auth/refresh',
'/cabinet/auth/password/forgot',
'/cabinet/auth/password/reset',
'/cabinet/auth/oauth/',
];
function isAuthEndpoint(url: string | undefined): boolean {
if (!url) return false;
return AUTH_ENDPOINTS.some((endpoint) => url.includes(endpoint));
}
// Request interceptor - add auth token with expiration check
apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
// Skip token refresh and Bearer header for auth endpoints
// These endpoints authenticate via init_data/credentials, not Bearer tokens
if (!isAuthEndpoint(config.url)) {
let token = tokenStorage.getAccessToken();
// Проверяем срок действия токена перед запросом
if (token && isTokenExpired(token)) {
// Используем централизованный менеджер для refresh
const newToken = await tokenRefreshManager.refreshAccessToken();
if (newToken) {
token = newToken;
} else {
// Refresh не удался - редирект на логин
tokenStorage.clearTokens();
safeRedirectToLogin();
return config;
}
}
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
}
const telegramInitData = getTelegramInitData();
if (telegramInitData && config.headers) {
config.headers['X-Telegram-Init-Data'] = telegramInitData;
}
// Add CSRF token for state-changing methods
const method = config.method?.toUpperCase();
if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) {
config.headers[CSRF_HEADER_NAME] = ensureCsrfToken();
}
return config;
});
// Custom error types for special handling
export interface MaintenanceError {
code: 'maintenance';
message: string;
reason?: string;
}
export interface ChannelSubscriptionError {
code: 'channel_subscription_required';
message: string;
channel_link?: string;
}
export interface BlacklistedError {
code: 'blacklisted';
message: string;
}
export function isMaintenanceError(
error: unknown,
): error is { response: { status: 503; data: { detail: MaintenanceError } } } {
if (!error || typeof error !== 'object') return false;
const err = error as AxiosError<{ detail: MaintenanceError }>;
return err.response?.status === 503 && err.response?.data?.detail?.code === 'maintenance';
}
export function isChannelSubscriptionError(
error: unknown,
): error is { response: { status: 403; data: { detail: ChannelSubscriptionError } } } {
if (!error || typeof error !== 'object') return false;
const err = error as AxiosError<{ detail: ChannelSubscriptionError }>;
return (
err.response?.status === 403 &&
err.response?.data?.detail?.code === 'channel_subscription_required'
);
}
export function isBlacklistedError(
error: unknown,
): error is { response: { status: 403; data: { detail: BlacklistedError } } } {
if (!error || typeof error !== 'object') return false;
const err = error as AxiosError<{ detail: BlacklistedError }>;
return err.response?.status === 403 && err.response?.data?.detail?.code === 'blacklisted';
}
// Response interceptor - handle 401, 503 (maintenance), 403 (channel subscription)
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Handle maintenance mode (503)
if (isMaintenanceError(error)) {
const detail = (error.response?.data as { detail: MaintenanceError }).detail;
useBlockingStore.getState().setMaintenance({
message: detail.message,
reason: detail.reason,
});
return Promise.reject(error);
}
// Handle channel subscription required (403)
if (isChannelSubscriptionError(error)) {
const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail;
useBlockingStore.getState().setChannelSubscription({
message: detail.message,
channel_link: detail.channel_link,
});
return Promise.reject(error);
}
// Handle blacklisted user (403)
if (isBlacklistedError(error)) {
const detail = (error.response?.data as { detail: BlacklistedError }).detail;
useBlockingStore.getState().setBlacklisted({
message: detail.message,
});
return Promise.reject(error);
}
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
if (error.response?.status === 401 && !originalRequest._retry) {
// Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента
const authEndpoints = [
'/cabinet/auth/email/login',
'/cabinet/auth/telegram',
'/cabinet/auth/telegram/widget',
];
const requestUrl = originalRequest.url || '';
const isAuthEndpoint = authEndpoints.some((endpoint) => requestUrl.includes(endpoint));
if (isAuthEndpoint) {
// Пробрасываем ошибку в компонент для показа сообщения пользователю
return Promise.reject(error);
}
originalRequest._retry = true;
const newToken = await tokenRefreshManager.refreshAccessToken();
if (newToken) {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
}
return apiClient(originalRequest);
} else {
// Refresh не удался
tokenStorage.clearTokens();
safeRedirectToLogin();
}
}
return Promise.reject(error);
},
);
export default apiClient;