From ea1e5bbf6a41dc7de9f7b2a2fa4fc32cd1caea3e Mon Sep 17 00:00:00 2001
From: SERTY
Date: Tue, 3 Feb 2026 09:32:02 +0300
Subject: [PATCH] =?UTF-8?q?0302-=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB?=
=?UTF-8?q?=20=D0=BA=D1=83=D0=BA=D0=B8=20=D0=B8=20=D1=81=D0=BB=D0=BE=D0=BC?=
=?UTF-8?q?=D0=B0=D0=BB=20=D0=B4=D0=B5=D1=81=D0=BA=D1=82=D0=BE=D0=BF=20?=
=?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?=
=?UTF-8?q?=D1=8E.=20=D1=81=D0=BB=D0=BE=D0=B6=D0=BD=D0=BE=20=D0=BF=D0=BE?=
=?UTF-8?q?=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20?=
=?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=BE=D1=8F=D0=B9=D1=86=D0=B5=D0=B2=D1=8B?=
=?UTF-8?q?=D1=85=20=D0=B1=D0=BB=D0=B8=D0=B7=D0=BD=D0=B5=D1=86=D0=BE=D0=B2?=
=?UTF-8?q?=20-=20desktop=20=D0=B8=20TMA,=20=D0=BF=D0=BE=D0=B4=D0=B3=D0=BE?=
=?UTF-8?q?=D1=82=D0=BE=D0=B2=D0=B8=D0=BB=20=D0=BA=20=D1=80=D0=B5=D1=84?=
=?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=D1=83=20=D1=81?=
=?UTF-8?q?=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D1=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.kilocodemodes | 14 +++
cmd/main.go | 8 +-
internal/domain/account/entity.go | 18 +++
internal/infrastructure/db/postgres.go | 1 +
.../repository/account/postgres.go | 29 +++++
internal/services/auth/service.go | 82 +++++++++++++-
internal/transport/http/handlers/auth.go | 77 ++++++++++---
internal/transport/http/middleware/auth.go | 70 +++++++++---
rmser-view/src/App.tsx | 5 +-
.../src/components/layout/SessionGuard.tsx | 78 +++++++++++++
rmser-view/src/hooks/useWebSocket.ts | 99 +++++++++++++++--
.../layouts/DesktopLayout/DesktopHeader.tsx | 4 +-
.../pages/desktop/auth/DesktopAuthScreen.tsx | 105 ++++++++++++------
rmser-view/src/services/api.ts | 43 +++++--
14 files changed, 547 insertions(+), 86 deletions(-)
create mode 100644 .kilocodemodes
create mode 100644 rmser-view/src/components/layout/SessionGuard.tsx
diff --git a/.kilocodemodes b/.kilocodemodes
new file mode 100644
index 0000000..0be3cd5
--- /dev/null
+++ b/.kilocodemodes
@@ -0,0 +1,14 @@
+customModes:
+ - slug: frontend-specialist
+ name: Frontend Specialist
+ roleDefinition: |
+ You are a frontend developer expert in React, TypeScript, and modern CSS. You focus on creating intuitive user interfaces and excellent user experiences.
+ groups:
+ - read
+ - browser
+ - - edit
+ - fileRegex: \.(tsx?|jsx?|css|scss|less)$
+ description: Frontend files only
+ customInstructions: |
+ Prioritize accessibility, responsive design, and performance. Use semantic HTML and follow React best practices.
+ source: project
diff --git a/cmd/main.go b/cmd/main.go
index e61a1cd..f8e85fb 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -135,7 +135,7 @@ func main() {
settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)
settingsHandler.SetRMSFactory(rmsFactory)
invoicesHandler := handlers.NewInvoiceHandler(invoicesService, syncService)
- authHandler := handlers.NewAuthHandler(authService, cfg.Telegram.BotUsername)
+ authHandler := handlers.NewAuthHandler(authService, accountRepo, cfg.Telegram.BotUsername)
// 10. Telegram Bot (Передаем syncService и authService)
if cfg.Telegram.Token != "" {
@@ -176,8 +176,12 @@ func main() {
// Хендлер инициализации desktop авторизации (без middleware)
api.POST("/auth/init-desktop", authHandler.InitDesktopAuth)
+ // Новые endpoints для сессионной авторизации
+ api.POST("/auth/session", authHandler.CreateSession)
+ api.GET("/auth/me", authHandler.GetMe)
+ api.POST("/auth/logout", authHandler.Logout)
- api.Use(middleware.AuthMiddleware(accountRepo, cfg.Telegram.Token, cfg.Security.SecretKey, cfg.App.MaintenanceMode, cfg.App.DevIDs))
+ api.Use(middleware.AuthMiddleware(accountRepo, authService, cfg.Telegram.Token, cfg.Security.SecretKey, cfg.App.MaintenanceMode, cfg.App.DevIDs))
{
// Drafts & Invoices
api.GET("/drafts", draftsHandler.GetDrafts)
diff --git a/internal/domain/account/entity.go b/internal/domain/account/entity.go
index 5be275d..8ed3b4b 100644
--- a/internal/domain/account/entity.go
+++ b/internal/domain/account/entity.go
@@ -88,6 +88,18 @@ type RMSServer struct {
UpdatedAt time.Time `json:"updated_at"`
}
+// Session - Сессия пользователя для аутентификации
+type Session struct {
+ ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
+ UserID uuid.UUID `gorm:"type:uuid;not null;index"`
+ RefreshToken string `gorm:"type:varchar(255);not null;index"` // Уникальный токен сессии
+ UserAgent string `gorm:"type:varchar(255)"`
+ IP string `gorm:"type:varchar(50)"`
+ ExpiresAt time.Time `gorm:"index"`
+ CreatedAt time.Time
+ UpdatedAt time.Time // Для отслеживания sliding expiration
+}
+
// Repository интерфейс
type Repository interface {
// Users
@@ -151,4 +163,10 @@ type Repository interface {
UpdateLastSync(serverID uuid.UUID) error
// GetServersForSync возвращает серверы, готовые для синхронизации
GetServersForSync(idleThreshold time.Duration) ([]RMSServer, error)
+
+ // === Session Management ===
+ CreateSession(session *Session) error
+ GetSessionByToken(token string) (*Session, error)
+ UpdateSessionExpiry(sessionID uuid.UUID, newExpiry time.Time) error
+ DeleteSession(token string) error
}
diff --git a/internal/infrastructure/db/postgres.go b/internal/infrastructure/db/postgres.go
index 6559b95..b21247b 100644
--- a/internal/infrastructure/db/postgres.go
+++ b/internal/infrastructure/db/postgres.go
@@ -53,6 +53,7 @@ func NewPostgresDB(dsn string) *gorm.DB {
&account.User{},
&account.RMSServer{},
&account.ServerUser{},
+ &account.Session{},
&billing.Order{},
&catalog.Product{},
&catalog.MeasureUnit{},
diff --git a/internal/infrastructure/repository/account/postgres.go b/internal/infrastructure/repository/account/postgres.go
index 9d6a6c1..3b7766c 100644
--- a/internal/infrastructure/repository/account/postgres.go
+++ b/internal/infrastructure/repository/account/postgres.go
@@ -552,3 +552,32 @@ func (r *pgRepository) SetMuteDraftNotifications(userID, serverID uuid.UUID, mut
Where("user_id = ? AND server_id = ?", userID, serverID).
Update("mute_draft_notifications", mute).Error
}
+
+// === Session Management ===
+
+// CreateSession создает новую сессию пользователя
+func (r *pgRepository) CreateSession(session *account.Session) error {
+ return r.db.Create(session).Error
+}
+
+// GetSessionByToken ищет сессию по токену
+func (r *pgRepository) GetSessionByToken(token string) (*account.Session, error) {
+ var session account.Session
+ if err := r.db.Where("refresh_token = ?", token).First(&session).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ return &session, nil
+}
+
+// UpdateSessionExpiry обновляет время истечения сессии (sliding expiration)
+func (r *pgRepository) UpdateSessionExpiry(sessionID uuid.UUID, newExpiry time.Time) error {
+ return r.db.Model(&account.Session{}).Where("id = ?", sessionID).Update("expires_at", newExpiry).Error
+}
+
+// DeleteSession удаляет сессию по токену
+func (r *pgRepository) DeleteSession(token string) error {
+ return r.db.Where("refresh_token = ?", token).Delete(&account.Session{}).Error
+}
diff --git a/internal/services/auth/service.go b/internal/services/auth/service.go
index 8fe4be3..824c4a5 100644
--- a/internal/services/auth/service.go
+++ b/internal/services/auth/service.go
@@ -1,7 +1,10 @@
package auth
import (
+ "crypto/rand"
+ "encoding/hex"
"errors"
+ "fmt"
"time"
"rmser/internal/domain/account"
@@ -22,9 +25,9 @@ type Claims struct {
// Service представляет сервис авторизации для desktop auth
type Service struct {
- accountRepo account.Repository
- wsServer *ws.Server
- secretKey string
+ accountRepo account.Repository
+ wsServer *ws.Server
+ secretKey string
}
// NewService создает новый экземпляр сервиса авторизации
@@ -105,3 +108,76 @@ func (s *Service) generateJWTToken(user *account.User) (string, error) {
return tokenString, nil
}
+
+// generateRefreshToken генерирует безопасный refresh токен
+func (s *Service) generateRefreshToken() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b), nil
+}
+
+// CreateWebSession создает новую веб-сессию для пользователя
+func (s *Service) CreateWebSession(userID uuid.UUID, userAgent, ip string) (*account.Session, error) {
+ token, err := s.generateRefreshToken()
+ if err != nil {
+ logger.Log.Error("failed to generate refresh token", zap.Error(err))
+ return nil, err
+ }
+
+ session := &account.Session{
+ UserID: userID,
+ RefreshToken: token,
+ UserAgent: userAgent,
+ IP: ip,
+ ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
+ }
+
+ if err := s.accountRepo.CreateSession(session); err != nil {
+ logger.Log.Error("failed to create session", zap.Error(err))
+ return nil, err
+ }
+
+ return session, nil
+}
+
+// ValidateAndExtendSession проверяет и продлевает сессию (sliding window)
+func (s *Service) ValidateAndExtendSession(token string) (*account.User, error) {
+ session, err := s.accountRepo.GetSessionByToken(token)
+ if err != nil {
+ logger.Log.Error("failed to get session", zap.Error(err))
+ return nil, err
+ }
+
+ if session == nil {
+ return nil, fmt.Errorf("session not found")
+ }
+
+ if time.Now().After(session.ExpiresAt) {
+ // Сессия истекла, удаляем её
+ s.accountRepo.DeleteSession(token)
+ return nil, fmt.Errorf("session expired")
+ }
+
+ // Sliding expiration: обновляем expiresAt на 7 дней от текущего момента
+ newExpiry := time.Now().Add(7 * 24 * time.Hour)
+ if err := s.accountRepo.UpdateSessionExpiry(session.ID, newExpiry); err != nil {
+ logger.Log.Error("failed to update session expiry", zap.Error(err))
+ // Не критичная ошибка, продолжаем
+ }
+
+ // Получаем пользователя
+ user, err := s.accountRepo.GetUserByID(session.UserID)
+ if err != nil {
+ logger.Log.Error("failed to get user", zap.Error(err))
+ return nil, err
+ }
+
+ return user, nil
+}
+
+// DeleteSession удаляет сессию пользователя
+func (s *Service) DeleteSession(token string) error {
+ return s.accountRepo.DeleteSession(token)
+}
diff --git a/internal/transport/http/handlers/auth.go b/internal/transport/http/handlers/auth.go
index b988556..e9bc099 100644
--- a/internal/transport/http/handlers/auth.go
+++ b/internal/transport/http/handlers/auth.go
@@ -1,34 +1,33 @@
package handlers
import (
- "fmt"
"net/http"
+ "rmser/internal/domain/account"
"rmser/internal/services/auth"
- "rmser/pkg/logger"
"github.com/gin-gonic/gin"
- "go.uber.org/zap"
+ "github.com/google/uuid"
)
// AuthHandler обрабатывает HTTP запросы авторизации
type AuthHandler struct {
- service *auth.Service
- botUsername string
+ authService *auth.Service
+ accountService account.Repository
+ botUsername string
}
// NewAuthHandler создает новый экземпляр AuthHandler
-func NewAuthHandler(s *auth.Service, botUsername string) *AuthHandler {
- return &AuthHandler{service: s, botUsername: botUsername}
+func NewAuthHandler(s *auth.Service, accountRepo account.Repository, botUsername string) *AuthHandler {
+ return &AuthHandler{authService: s, accountService: accountRepo, botUsername: botUsername}
}
// InitDesktopAuth инициализирует desktop авторизацию
// POST /api/auth/init-desktop
func (h *AuthHandler) InitDesktopAuth(c *gin.Context) {
// Вызываем сервис для генерации session_id
- sessionID, err := h.service.InitDesktopAuth()
+ sessionID, err := h.authService.InitDesktopAuth()
if err != nil {
- logger.Log.Error("Ошибка инициализации desktop авторизации", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Ошибка инициализации авторизации",
})
@@ -36,12 +35,7 @@ func (h *AuthHandler) InitDesktopAuth(c *gin.Context) {
}
// Формируем QR URL для Telegram бота
- qrURL := fmt.Sprintf("https://t.me/%s?start=auth_%s", h.botUsername, sessionID)
-
- logger.Log.Info("Desktop авторизация инициализирована",
- zap.String("session_id", sessionID),
- zap.String("qr_url", qrURL),
- )
+ qrURL := "https://t.me/" + h.botUsername + "?start=auth_" + sessionID
// Возвращаем ответ с session_id и qr_url
c.JSON(http.StatusOK, gin.H{
@@ -49,3 +43,56 @@ func (h *AuthHandler) InitDesktopAuth(c *gin.Context) {
"qr_url": qrURL,
})
}
+
+// CreateSession создает HTTP сессию и устанавливает HttpOnly Cookie
+func (h *AuthHandler) CreateSession(c *gin.Context) {
+ userID, exists := c.Get("userID")
+ if !exists {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found in context"})
+ return
+ }
+
+ userAgent := c.Request.UserAgent()
+ ip := c.ClientIP()
+
+ session, err := h.authService.CreateWebSession(userID.(uuid.UUID), userAgent, ip)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Устанавливаем HttpOnly Cookie на 7 дней
+ c.SetCookie("rmser_session", session.RefreshToken, 7*24*60*60, "/", "", false, true)
+
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+}
+
+// GetMe возвращает информацию о текущем пользователе
+func (h *AuthHandler) GetMe(c *gin.Context) {
+ userID, exists := c.Get("userID")
+ if !exists {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
+ return
+ }
+
+ user, err := h.accountService.GetUserByID(userID.(uuid.UUID))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, user)
+}
+
+// Logout удаляет сессию и очищает cookie
+func (h *AuthHandler) Logout(c *gin.Context) {
+ token, err := c.Cookie("rmser_session")
+ if err == nil && token != "" {
+ h.authService.DeleteSession(token)
+ }
+
+ // Очищаем куку
+ c.SetCookie("rmser_session", "", -1, "/", "", false, true)
+
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+}
diff --git a/internal/transport/http/middleware/auth.go b/internal/transport/http/middleware/auth.go
index 8aa6dec..6f6b1ba 100644
--- a/internal/transport/http/middleware/auth.go
+++ b/internal/transport/http/middleware/auth.go
@@ -26,27 +26,28 @@ type Claims struct {
}
// AuthMiddleware проверяет JWT токен (Desktop) или initData от Telegram
-func AuthMiddleware(accountRepo account.Repository, botToken string, secretKey string, maintenanceMode bool, devIDs []int64) gin.HandlerFunc {
+func AuthMiddleware(accountRepo account.Repository, authService interface {
+ ValidateAndExtendSession(token string) (*account.User, error)
+}, botToken string, secretKey string, maintenanceMode bool, devIDs []int64) gin.HandlerFunc {
return func(c *gin.Context) {
- // 1. Извлекаем данные авторизации
- authHeader := c.GetHeader("Authorization")
- var authData string
+ // 1. Пробуем извлечь токен из Authorization header
+ tokenString := extractToken(c)
- if strings.HasPrefix(authHeader, "Bearer ") {
- authData = strings.TrimPrefix(authHeader, "Bearer ")
- } else {
- // Оставляем лазейку для отладки ТОЛЬКО если это не production режим
- // В реальности лучше всегда требовать подпись
- authData = c.Query("_auth")
+ // 2. Если нет токена в header, проверяем куку rmser_session
+ if tokenString == "" {
+ cookie, err := c.Cookie("rmser_session")
+ if err == nil && cookie != "" {
+ tokenString = cookie
+ }
}
- if authData == "" {
+ if tokenString == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Авторизация отклонена: отсутствует токен или подпись"})
return
}
- // 2. Попытка 1: Проверяем JWT токен (Desktop)
- token, err := jwt.ParseWithClaims(authData, &Claims{}, func(token *jwt.Token) (interface{}, error) {
+ // 3. Попытка 1: Проверяем JWT токен (Desktop)
+ token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("неожиданный метод подписи: %v", token.Header["alg"])
}
@@ -78,15 +79,42 @@ func AuthMiddleware(accountRepo account.Repository, botToken string, secretKey s
}
}
- // 3. Попытка 2: Проверяем Telegram InitData
- isValid, err := verifyTelegramInitData(authData, botToken)
+ // 4. Попытка 2: Проверяем сессию через authService (HttpOnly Cookie)
+ // Важно: проверяем длину, чтобы не слать initData (длинную строку) в БД как токен сессии
+ if authService != nil && len(tokenString) <= 128 {
+ user, err := authService.ValidateAndExtendSession(tokenString)
+ if err == nil && user != nil {
+ // Сессия валидна и продлена
+ if maintenanceMode {
+ isDev := false
+ for _, devID := range devIDs {
+ if user.TelegramID == devID {
+ isDev = true
+ break
+ }
+ }
+ if !isDev {
+ c.AbortWithStatusJSON(503, gin.H{"error": "maintenance_mode", "message": "Сервис на обслуживании"})
+ return
+ }
+ }
+
+ c.Set("userID", user.ID)
+ c.Set("telegramID", user.TelegramID)
+ c.Next()
+ return
+ }
+ }
+
+ // 5. Попытка 3: Проверяем Telegram InitData
+ isValid, err := verifyTelegramInitData(tokenString, botToken)
if !isValid || err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Критическая ошибка безопасности: недействительный токен или подпись"})
return
}
// Извлекаем User ID из проверенных данных
- values, _ := url.ParseQuery(authData)
+ values, _ := url.ParseQuery(tokenString)
userJSON := values.Get("user")
// Извлекаем id вручную из JSON-подобной строки или через простой парсинг
@@ -125,6 +153,16 @@ func AuthMiddleware(accountRepo account.Repository, botToken string, secretKey s
}
}
+// extractToken извлекает токен из Authorization header
+func extractToken(c *gin.Context) string {
+ authHeader := c.GetHeader("Authorization")
+ if strings.HasPrefix(authHeader, "Bearer ") {
+ return strings.TrimPrefix(authHeader, "Bearer ")
+ }
+ // Оставляем лазейку для отладки ТОЛЬКО если это не production режим
+ return c.Query("_auth")
+}
+
// verifyTelegramInitData реализует алгоритм проверки Telegram
func verifyTelegramInitData(initData, token string) (bool, error) {
values, err := url.ParseQuery(initData)
diff --git a/rmser-view/src/App.tsx b/rmser-view/src/App.tsx
index 2a26e7c..413845a 100644
--- a/rmser-view/src/App.tsx
+++ b/rmser-view/src/App.tsx
@@ -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 (
-
+
+
+
);
diff --git a/rmser-view/src/components/layout/SessionGuard.tsx b/rmser-view/src/components/layout/SessionGuard.tsx
new file mode 100644
index 0000000..0cb221e
--- /dev/null
+++ b/rmser-view/src/components/layout/SessionGuard.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/rmser-view/src/hooks/useWebSocket.ts b/rmser-view/src/hooks/useWebSocket.ts
index ec785b4..1aca2d8 100644
--- a/rmser-view/src/hooks/useWebSocket.ts
+++ b/rmser-view/src/hooks/useWebSocket.ts
@@ -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(null);
const [lastMessage, setLastMessage] = useState(null);
const wsRef = useRef(null);
-
+ const reconnectTimeoutRef = useRef | null>(null);
+ const reconnectAttemptsRef = useRef(0);
+ const currentSessionIdRef = useRef(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 };
};
diff --git a/rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx b/rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx
index fb5a047..82b84c9 100644
--- a/rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx
+++ b/rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx
@@ -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";
};
diff --git a/rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx b/rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx
index aa58a2f..e2dfec7 100644
--- a/rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx
+++ b/rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx
@@ -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(null);
const [qrLink, setQrLink] = useState(null);
- const [loading, setLoading] = useState(true);
const [error, setError] = useState(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 (
{
backgroundColor: "#f0f2f5",
}}
>
-
+
);
}
@@ -100,6 +128,11 @@ export const DesktopAuthScreen: React.FC = () => {
type="error"
showIcon
style={{ maxWidth: "400px" }}
+ action={
+
+ }
/>
);
@@ -169,9 +202,19 @@ export const DesktopAuthScreen: React.FC = () => {
Disconnected
)}
- Session: {sessionId}
+ Session: {sessionId?.slice(0, 8)}...
{lastError && Error: {lastError}
}
+
+ {/* Кнопка ручного обновления QR */}
+ }
+ onClick={refreshSession}
+ style={{ marginTop: 12 }}
+ >
+ Обновить QR
+
);
diff --git a/rmser-view/src/services/api.ts b/rmser-view/src/services/api.ts
index 23d865e..36ba940 100644
--- a/rmser-view/src/services/api.ts
+++ b/rmser-view/src/services/api.ts
@@ -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 => {
+ await apiClient.post('/auth/session');
+ },
+
+ getMe: async (): Promise => {
+ const { data } = await apiClient.get('/auth/me');
+ return data;
+ },
+
+ logout: async (): Promise => {
+ await apiClient.post('/auth/logout');
+ },
+
// --- Управление серверами ---
getUserServers: async (): Promise => {