mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
0302-добавил куки и сломал десктоп авторизацию.
сложно поддерживать однояйцевых близнецов - desktop и TMA, подготовил к рефакторингу структуры
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { SessionGuard } from "./components/layout/SessionGuard";
|
||||
import {
|
||||
BrowserRouter,
|
||||
Routes,
|
||||
@@ -211,7 +212,9 @@ function App() {
|
||||
return (
|
||||
<Providers>
|
||||
<BrowserRouter>
|
||||
<AppContent />
|
||||
<SessionGuard>
|
||||
<AppContent />
|
||||
</SessionGuard>
|
||||
</BrowserRouter>
|
||||
</Providers>
|
||||
);
|
||||
|
||||
78
rmser-view/src/components/layout/SessionGuard.tsx
Normal file
78
rmser-view/src/components/layout/SessionGuard.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Spin } from "antd";
|
||||
import { api } from "../../services/api";
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
|
||||
interface SessionGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент для проверки сессии при загрузке десктопного приложения.
|
||||
* Если пользователь авторизован через куки - редиректит на dashboard.
|
||||
* В Telegram Mini App проверка не требуется - сразу отдаём children.
|
||||
*/
|
||||
export function SessionGuard({ children }: SessionGuardProps) {
|
||||
// Проверяем, находимся ли мы в Telegram - если да, пропускаем без проверки
|
||||
const isTelegram = !!window.Telegram?.WebApp?.initData;
|
||||
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// В Telegram проверка сессии не требуется
|
||||
if (isTelegram) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkSession = async () => {
|
||||
// Если пользователь уже авторизован в store - пропускаем
|
||||
if (isAuthenticated) {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Проверяем сессию через API
|
||||
const user = await api.getMe();
|
||||
// Если успех - сохраняем в store и редиректим
|
||||
useAuthStore.getState().setUser(user);
|
||||
useAuthStore.getState().setToken("cookie");
|
||||
navigate("/web/dashboard", { replace: true });
|
||||
} catch {
|
||||
// 401 - нет сессии, остаёмся на странице входа
|
||||
// Другие ошибки - тоже остаёмся
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkSession();
|
||||
}, [isTelegram, isAuthenticated, navigate]);
|
||||
|
||||
// В Telegram сразу пропускаем
|
||||
if (isTelegram) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Если проверяем - показываем лоадер
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
<Spin size="large" tip="Проверка сессии..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
|
||||
const apiUrl = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
@@ -23,18 +23,98 @@ interface WsEvent {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export const useWebSocket = (sessionId: string | null) => {
|
||||
interface UseWebSocketParams {
|
||||
autoReconnect?: boolean;
|
||||
onDisconnect?: () => void;
|
||||
}
|
||||
|
||||
export const useWebSocket = (sessionId: string | null, params: UseWebSocketParams = {}) => {
|
||||
const { autoReconnect = false, onDisconnect } = params;
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
const [lastMessage, setLastMessage] = useState<WsEvent | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const currentSessionIdRef = useRef<string | null>(null);
|
||||
|
||||
// Используем ref для хранения функции reconnect, чтобы она могла вызывать сама себя
|
||||
const reconnectRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Функция для переподключения WebSocket
|
||||
const reconnect = useCallback(() => {
|
||||
if (!currentSessionIdRef.current) return;
|
||||
|
||||
const delay = Math.min(1000 + reconnectAttemptsRef.current * 1000, 5000);
|
||||
reconnectAttemptsRef.current++;
|
||||
|
||||
console.log(`🔄 WS Reconnect attempt ${reconnectAttemptsRef.current} in ${delay}ms`);
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
const url = `${getWsUrl()}?session_id=${currentSessionIdRef.current}`;
|
||||
console.log('🔌 Reconnecting WS:', url);
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('✅ WS Reconnected');
|
||||
setIsConnected(true);
|
||||
setLastError(null);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
// Используем ref для вызова reconnect
|
||||
reconnectRef.current?.();
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('❌ WS Error', error);
|
||||
setLastError('Connection error');
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data);
|
||||
console.log('📨 WS Message:', parsed);
|
||||
setLastMessage(parsed);
|
||||
} catch {
|
||||
console.error('Failed to parse WS message', event.data);
|
||||
}
|
||||
};
|
||||
}, delay);
|
||||
}, []);
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
if (onDisconnect) {
|
||||
onDisconnect();
|
||||
}
|
||||
|
||||
if (autoReconnect) {
|
||||
reconnect();
|
||||
}
|
||||
}, [onDisconnect, reconnect, autoReconnect]);
|
||||
|
||||
useEffect(() => {
|
||||
// Сохраняем reconnect в ref после его создания
|
||||
reconnectRef.current = reconnect;
|
||||
}, [reconnect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
|
||||
currentSessionIdRef.current = sessionId;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
||||
const url = `${getWsUrl()}?session_id=${sessionId}`;
|
||||
console.log('🔌 Connecting Native WS:', url);
|
||||
console.log('🔌 Connecting WS:', url);
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
@@ -48,6 +128,7 @@ export const useWebSocket = (sessionId: string | null) => {
|
||||
ws.onclose = (event) => {
|
||||
console.log('⚠️ WS Closed', event.code, event.reason);
|
||||
setIsConnected(false);
|
||||
handleDisconnect();
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
@@ -68,9 +149,13 @@ export const useWebSocket = (sessionId: string | null) => {
|
||||
|
||||
return () => {
|
||||
console.log('🧹 WS Cleanup');
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
ws.close();
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
}, [sessionId, handleDisconnect]);
|
||||
|
||||
return { isConnected, lastError, lastMessage };
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Layout, Space, Avatar, Dropdown, Select } from "antd";
|
||||
import { UserOutlined, LogoutOutlined } from "@ant-design/icons";
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import { useServerStore } from "../../stores/serverStore";
|
||||
import { api } from "../../services/api";
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
@@ -20,7 +21,8 @@ export const DesktopHeader: React.FC = () => {
|
||||
fetchServers();
|
||||
}, [fetchServers]);
|
||||
|
||||
const handleLogout = () => {
|
||||
const handleLogout = async () => {
|
||||
await api.logout();
|
||||
logout();
|
||||
window.location.href = "/web";
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Card, Typography, Spin, Alert, message, Button } from "antd";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { SendOutlined } from "@ant-design/icons";
|
||||
import { SendOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useWebSocket } from "../../../hooks/useWebSocket";
|
||||
import { useAuthStore } from "../../../stores/authStore";
|
||||
@@ -22,51 +22,76 @@ interface AuthSuccessData {
|
||||
/**
|
||||
* Экран авторизации для десктопной версии
|
||||
* Отображает QR код для авторизации через мобильное приложение
|
||||
* Реализует самовосстановление при разрыве соединения
|
||||
*/
|
||||
export const DesktopAuthScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { setToken, setUser } = useAuthStore();
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [qrLink, setQrLink] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const { isConnected, lastError, lastMessage } = useWebSocket(sessionId);
|
||||
// Функция обновления сессии QR
|
||||
const refreshSession = useCallback(async () => {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
|
||||
const data = await api.initDesktopAuth();
|
||||
setSessionId(data.session_id);
|
||||
setQrLink(data.qr_url);
|
||||
console.log("🔄 Session refreshed:", data.session_id);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Неизвестная ошибка при обновлении QR";
|
||||
setError(errorMessage);
|
||||
console.error("❌ Refresh error:", err);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Обработка разрыва соединения - автообновление QR
|
||||
const handleDisconnect = useCallback(() => {
|
||||
console.log("⚠️ WebSocket disconnected, refreshing QR...");
|
||||
refreshSession();
|
||||
}, [refreshSession]);
|
||||
|
||||
// Инициализация WebSocket с отключенным автореконнектом
|
||||
const { isConnected, lastError, lastMessage } = useWebSocket(sessionId, {
|
||||
autoReconnect: false,
|
||||
onDisconnect: handleDisconnect,
|
||||
});
|
||||
|
||||
// Инициализация сессии авторизации при маунте
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = await api.initDesktopAuth();
|
||||
setSessionId(data.session_id);
|
||||
setQrLink(data.qr_url);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Неизвестная ошибка");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
refreshSession();
|
||||
}, [refreshSession]);
|
||||
|
||||
// Обработка события успешной авторизации через WebSocket
|
||||
useEffect(() => {
|
||||
if (lastMessage && lastMessage.event === "auth_success") {
|
||||
const data = lastMessage.data as AuthSuccessData;
|
||||
const { token, user } = data;
|
||||
console.log("🎉 Auth Success:", user);
|
||||
setToken(token);
|
||||
setUser(user);
|
||||
message.success("Вход выполнен!");
|
||||
navigate("/web/dashboard");
|
||||
const handleAuthSuccess = async () => {
|
||||
const data = lastMessage.data as AuthSuccessData;
|
||||
const { token, user } = data;
|
||||
console.log("🎉 Auth Success:", user);
|
||||
|
||||
// Создаём сессию на сервере (установка HttpOnly куки)
|
||||
await api.createSession();
|
||||
|
||||
// Устанавливаем токен и данные пользователя
|
||||
setToken(token);
|
||||
setUser(user);
|
||||
message.success("Вход выполнен!");
|
||||
navigate("/web/dashboard");
|
||||
};
|
||||
|
||||
handleAuthSuccess();
|
||||
}
|
||||
}, [lastMessage, setToken, setUser, navigate]);
|
||||
|
||||
if (loading) {
|
||||
// Отображение лоадера при обновлении QR
|
||||
if (isRefreshing) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -77,7 +102,10 @@ export const DesktopAuthScreen: React.FC = () => {
|
||||
backgroundColor: "#f0f2f5",
|
||||
}}
|
||||
>
|
||||
<Spin size="large" tip="Загрузка..." />
|
||||
<Spin
|
||||
size="large"
|
||||
tip="Обновление QR..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -100,6 +128,11 @@ export const DesktopAuthScreen: React.FC = () => {
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ maxWidth: "400px" }}
|
||||
action={
|
||||
<Button size="small" type="primary" danger onClick={refreshSession}>
|
||||
Повторить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -169,9 +202,19 @@ export const DesktopAuthScreen: React.FC = () => {
|
||||
<span style={{ color: "red" }}>Disconnected</span>
|
||||
)}
|
||||
</p>
|
||||
<p>Session: {sessionId}</p>
|
||||
<p>Session: {sessionId?.slice(0, 8)}...</p>
|
||||
{lastError && <p style={{ color: "red" }}>Error: {lastError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Кнопка ручного обновления QR */}
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={refreshSession}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
Обновить QR
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import axios from 'axios';
|
||||
import { notification } from 'antd';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
// Тип пользователя для сессионной авторизации
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
}
|
||||
import type {
|
||||
CatalogItem,
|
||||
CreateInvoiceRequest,
|
||||
@@ -64,30 +72,31 @@ export const apiClient = axios.create({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// --- Request Interceptor (Авторизация через JWT или initData) ---
|
||||
// --- Request Interceptor (Авторизация через initData или JWT) ---
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
// Шаг 1: Whitelist - пропускаем запросы к инициализации десктопной авторизации
|
||||
if (config.url?.endsWith('/auth/init-desktop')) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// Шаг 2: Desktop Auth - проверяем JWT токен из authStore
|
||||
const jwtToken = useAuthStore.getState().token;
|
||||
if (jwtToken) {
|
||||
config.headers['Authorization'] = `Bearer ${jwtToken}`;
|
||||
return config;
|
||||
}
|
||||
|
||||
// Шаг 3: Mobile Auth - проверяем Telegram initData
|
||||
// Шаг 2: Mobile Auth (Telegram Mini App) - проверяем Telegram initData ПЕРВЫМ (высший приоритет)
|
||||
const initData = tg?.initData;
|
||||
if (initData) {
|
||||
config.headers['Authorization'] = `Bearer ${initData}`;
|
||||
return config;
|
||||
}
|
||||
|
||||
// Шаг 4: Block - если нет ни JWT, ни initData, отклоняем запрос
|
||||
// Шаг 3: Desktop Auth - проверяем JWT токен из authStore
|
||||
const jwtToken = useAuthStore.getState().token;
|
||||
if (jwtToken) {
|
||||
config.headers['Authorization'] = `Bearer ${jwtToken}`;
|
||||
return config;
|
||||
}
|
||||
|
||||
// Шаг 4: Block - если нет ни initData, ни JWT, отклоняем запрос
|
||||
console.error('Запрос заблокирован: отсутствуют данные авторизации.');
|
||||
return Promise.reject(new Error('MISSING_AUTH'));
|
||||
});
|
||||
@@ -328,6 +337,20 @@ export const api = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// Сессионная авторизация (Desktop через куки)
|
||||
createSession: async (): Promise<void> => {
|
||||
await apiClient.post('/auth/session');
|
||||
},
|
||||
|
||||
getMe: async (): Promise<User> => {
|
||||
const { data } = await apiClient.get<User>('/auth/me');
|
||||
return data;
|
||||
},
|
||||
|
||||
logout: async (): Promise<void> => {
|
||||
await apiClient.post('/auth/logout');
|
||||
},
|
||||
|
||||
// --- Управление серверами ---
|
||||
|
||||
getUserServers: async (): Promise<ServerShort[]> => {
|
||||
|
||||
Reference in New Issue
Block a user