From b99e328d35140f1d3ec81acdf240631082d993ce Mon Sep 17 00:00:00 2001 From: SERTY Date: Wed, 28 Jan 2026 08:12:41 +0300 Subject: [PATCH] =?UTF-8?q?2801-=D0=B5=D1=81=D1=82=D1=8C=20=D0=B4=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D1=82=D0=BE=D0=BF-=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D1=8F.=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=20ws=20=D0=B4=D0=BB=D1=8F=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20=D1=82=D0=B3-=D0=B1=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/main.go | 31 +- config.yaml | 1 + config/config.go | 7 +- go.mod | 2 + go.sum | 4 + internal/services/auth/service.go | 107 ++ internal/transport/http/handlers/auth.go | 51 + internal/transport/http/middleware/auth.go | 68 +- internal/transport/telegram/bot.go | 27 + internal/transport/ws/server.go | 129 +++ rmser-view/nginx.conf | 8 + rmser-view/package-lock.json | 94 +- rmser-view/package.json | 2 + rmser-view/project_context.md | 987 +++++++++++++++++- rmser-view/src/App.tsx | 88 +- rmser-view/src/components/DragDropZone.tsx | 96 ++ rmser-view/src/hooks/usePlatform.ts | 33 + rmser-view/src/hooks/useWebSocket.ts | 76 ++ .../layouts/DesktopLayout/DesktopHeader.tsx | 86 ++ .../layouts/DesktopLayout/DesktopLayout.tsx | 30 + .../pages/desktop/auth/DesktopAuthScreen.tsx | 178 ++++ .../pages/desktop/auth/MobileBrowserStub.tsx | 37 + .../desktop/dashboard/InvoicesDashboard.tsx | 66 ++ rmser-view/src/services/api.ts | 50 +- rmser-view/src/stores/authStore.ts | 52 + rmser-view/src/stores/uiStore.ts | 30 + 26 files changed, 2258 insertions(+), 82 deletions(-) create mode 100644 internal/services/auth/service.go create mode 100644 internal/transport/http/handlers/auth.go create mode 100644 internal/transport/ws/server.go create mode 100644 rmser-view/src/components/DragDropZone.tsx create mode 100644 rmser-view/src/hooks/usePlatform.ts create mode 100644 rmser-view/src/hooks/useWebSocket.ts create mode 100644 rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx create mode 100644 rmser-view/src/layouts/DesktopLayout/DesktopLayout.tsx create mode 100644 rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx create mode 100644 rmser-view/src/pages/desktop/auth/MobileBrowserStub.tsx create mode 100644 rmser-view/src/pages/desktop/dashboard/InvoicesDashboard.tsx create mode 100644 rmser-view/src/stores/authStore.ts create mode 100644 rmser-view/src/stores/uiStore.ts diff --git a/cmd/main.go b/cmd/main.go index 7e77d04..77bf36e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -15,7 +15,9 @@ import ( "rmser/internal/infrastructure/ocr_client" "rmser/internal/infrastructure/yookassa" + "rmser/internal/services/auth" "rmser/internal/transport/http/middleware" + "rmser/internal/transport/ws" tgBot "rmser/internal/transport/telegram" // Repositories @@ -56,6 +58,11 @@ func main() { log.Fatalf("Ошибка загрузки конфига: %v", err) } + // Проверяем, что bot_username задан в конфиге + if cfg.Telegram.BotUsername == "" { + log.Fatalf("Telegram.BotUsername не задан в конфиге! Это обязательное поле для авторизации.") + } + // 2. Logger logger.Init(cfg.App.Mode) defer logger.Log.Sync() @@ -102,7 +109,14 @@ func main() { invoicesService := invoicesServicePkg.NewService(invoicesRepo, draftsRepo, supplierRepo, rmsFactory) photosService := photosServicePkg.NewService(photosRepo, draftsRepo, accountRepo) - // 7. Handlers + // 7. WebSocket сервер для desktop авторизации + wsServer := ws.NewServer() + go wsServer.Run() + + // 8. Сервис авторизации для desktop auth + authService := auth.NewService(accountRepo, wsServer, cfg.Security.SecretKey) + + // 9. Handlers draftsHandler := handlers.NewDraftsHandler(draftsService) billingHandler := handlers.NewBillingHandler(billingService) ocrHandler := handlers.NewOCRHandler(ocrService) @@ -110,10 +124,11 @@ func main() { recommendHandler := handlers.NewRecommendationsHandler(recService) settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo) invoicesHandler := handlers.NewInvoiceHandler(invoicesService, syncService) + authHandler := handlers.NewAuthHandler(authService, cfg.Telegram.BotUsername) - // 8. Telegram Bot (Передаем syncService) + // 10. Telegram Bot (Передаем syncService и authService) if cfg.Telegram.Token != "" { - bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, billingService, accountRepo, rmsFactory, cryptoManager, draftsService, cfg.App.MaintenanceMode, cfg.App.DevIDs) + bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, billingService, accountRepo, rmsFactory, cryptoManager, draftsService, authService, cfg.App.MaintenanceMode, cfg.App.DevIDs) if err != nil { logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err)) } @@ -125,12 +140,15 @@ func main() { defer bot.Stop() } - // 9. HTTP Server + // 11. HTTP Server if cfg.App.Mode == "release" { gin.SetMode(gin.ReleaseMode) } r := gin.Default() + // Регистрируем WebSocket хендлер + r.GET("/socket.io/", wsServer.HandleConnections) + r.POST("/api/webhooks/yookassa", billingHandler.YooKassaWebhook) corsConfig := cors.DefaultConfig() @@ -145,7 +163,10 @@ func main() { api := r.Group("/api") - api.Use(middleware.AuthMiddleware(accountRepo, cfg.Telegram.Token, cfg.App.MaintenanceMode, cfg.App.DevIDs)) + // Хендлер инициализации desktop авторизации (без middleware) + api.POST("/auth/init-desktop", authHandler.InitDesktopAuth) + + api.Use(middleware.AuthMiddleware(accountRepo, cfg.Telegram.Token, cfg.Security.SecretKey, cfg.App.MaintenanceMode, cfg.App.DevIDs)) { // Drafts & Invoices api.GET("/drafts", draftsHandler.GetDrafts) diff --git a/config.yaml b/config.yaml index 645b7d4..95d0c3a 100644 --- a/config.yaml +++ b/config.yaml @@ -23,6 +23,7 @@ security: telegram: token: "7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4" + bot_username: "resto_transport_bot" admin_ids: [665599275] web_app_url: "https://rmser.serty.top" diff --git a/config/config.go b/config/config.go index 462e923..cbfe14c 100644 --- a/config/config.go +++ b/config/config.go @@ -49,9 +49,10 @@ type OCRConfig struct { } type TelegramConfig struct { - Token string `mapstructure:"token"` - AdminIDs []int64 `mapstructure:"admin_ids"` - WebAppURL string `mapstructure:"web_app_url"` + Token string `mapstructure:"token"` + BotUsername string `mapstructure:"bot_username"` + AdminIDs []int64 `mapstructure:"admin_ids"` + WebAppURL string `mapstructure:"web_app_url"` } type SecurityConfig struct { diff --git a/go.mod b/go.mod index 484cb5c..d3a1175 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,9 @@ go 1.25.5 require ( github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.4.2 github.com/jackc/pgx/v5 v5.6.0 github.com/redis/go-redis/v9 v9.17.1 github.com/shopspring/decimal v1.4.0 diff --git a/go.sum b/go.sum index 92a1541..29d9031 100644 --- a/go.sum +++ b/go.sum @@ -173,6 +173,8 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -255,6 +257,8 @@ github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/Oth github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0= diff --git a/internal/services/auth/service.go b/internal/services/auth/service.go new file mode 100644 index 0000000..8fe4be3 --- /dev/null +++ b/internal/services/auth/service.go @@ -0,0 +1,107 @@ +package auth + +import ( + "errors" + "time" + + "rmser/internal/domain/account" + "rmser/internal/transport/ws" + "rmser/pkg/logger" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// Claims представляет JWT claims для токена авторизации +type Claims struct { + UserID uuid.UUID `json:"user_id"` + TelegramID int64 `json:"telegram_id"` + jwt.RegisteredClaims +} + +// Service представляет сервис авторизации для desktop auth +type Service struct { + accountRepo account.Repository + wsServer *ws.Server + secretKey string +} + +// NewService создает новый экземпляр сервиса авторизации +func NewService(accountRepo account.Repository, wsServer *ws.Server, secretKey string) *Service { + return &Service{ + accountRepo: accountRepo, + wsServer: wsServer, + secretKey: secretKey, + } +} + +// InitDesktopAuth генерирует уникальный session_id для desktop авторизации +func (s *Service) InitDesktopAuth() (string, error) { + sessionID := uuid.New().String() + + logger.Log.Info("Инициализация desktop авторизации", + zap.String("session_id", sessionID), + ) + + return sessionID, nil +} + +// ConfirmDesktopAuth подтверждает авторизацию и отправляет токен через Socket.IO +func (s *Service) ConfirmDesktopAuth(sessionID string, telegramID int64) error { + // Ищем пользователя по Telegram ID + user, err := s.accountRepo.GetUserByTelegramID(telegramID) + if err != nil { + logger.Log.Error("Пользователь не найден", + zap.Int64("telegram_id", telegramID), + zap.Error(err), + ) + return errors.New("пользователь не найден") + } + + // Генерируем JWT токен + token, err := s.generateJWTToken(user) + if err != nil { + logger.Log.Error("Ошибка генерации JWT токена", + zap.String("user_id", user.ID.String()), + zap.Error(err), + ) + return err + } + + // Отправляем токен через WebSocket + s.wsServer.SendAuthSuccess(sessionID, token, *user) + + logger.Log.Info("Desktop авторизация подтверждена", + zap.String("session_id", sessionID), + zap.String("user_id", user.ID.String()), + zap.Int64("telegram_id", telegramID), + ) + + return nil +} + +// generateJWTToken генерирует JWT токен для пользователя +func (s *Service) generateJWTToken(user *account.User) (string, error) { + // Создаем claims с данными пользователя + claims := Claims{ + UserID: user.ID, + TelegramID: user.TelegramID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // Токен действителен 24 часа + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + }, + } + + // Создаем токен с claims + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + // Подписываем токен секретным ключом + tokenString, err := token.SignedString([]byte(s.secretKey)) + if err != nil { + return "", err + } + + return tokenString, nil +} diff --git a/internal/transport/http/handlers/auth.go b/internal/transport/http/handlers/auth.go new file mode 100644 index 0000000..b988556 --- /dev/null +++ b/internal/transport/http/handlers/auth.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "fmt" + "net/http" + + "rmser/internal/services/auth" + "rmser/pkg/logger" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// AuthHandler обрабатывает HTTP запросы авторизации +type AuthHandler struct { + service *auth.Service + botUsername string +} + +// NewAuthHandler создает новый экземпляр AuthHandler +func NewAuthHandler(s *auth.Service, botUsername string) *AuthHandler { + return &AuthHandler{service: s, botUsername: botUsername} +} + +// InitDesktopAuth инициализирует desktop авторизацию +// POST /api/auth/init-desktop +func (h *AuthHandler) InitDesktopAuth(c *gin.Context) { + // Вызываем сервис для генерации session_id + sessionID, err := h.service.InitDesktopAuth() + if err != nil { + logger.Log.Error("Ошибка инициализации desktop авторизации", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Ошибка инициализации авторизации", + }) + return + } + + // Формируем 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), + ) + + // Возвращаем ответ с session_id и qr_url + c.JSON(http.StatusOK, gin.H{ + "session_id": sessionID, + "qr_url": qrURL, + }) +} diff --git a/internal/transport/http/middleware/auth.go b/internal/transport/http/middleware/auth.go index 86cdfcb..8aa6dec 100644 --- a/internal/transport/http/middleware/auth.go +++ b/internal/transport/http/middleware/auth.go @@ -14,37 +14,79 @@ import ( "rmser/internal/domain/account" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" ) -// AuthMiddleware проверяет initData от Telegram -func AuthMiddleware(accountRepo account.Repository, botToken string, maintenanceMode bool, devIDs []int64) gin.HandlerFunc { +// Claims представляет JWT claims для токена авторизации +type Claims struct { + UserID uuid.UUID `json:"user_id"` + TelegramID int64 `json:"telegram_id"` + jwt.RegisteredClaims +} + +// AuthMiddleware проверяет JWT токен (Desktop) или initData от Telegram +func AuthMiddleware(accountRepo account.Repository, botToken string, secretKey string, maintenanceMode bool, devIDs []int64) gin.HandlerFunc { return func(c *gin.Context) { // 1. Извлекаем данные авторизации authHeader := c.GetHeader("Authorization") - var initData string + var authData string if strings.HasPrefix(authHeader, "Bearer ") { - initData = strings.TrimPrefix(authHeader, "Bearer ") + authData = strings.TrimPrefix(authHeader, "Bearer ") } else { // Оставляем лазейку для отладки ТОЛЬКО если это не production режим // В реальности лучше всегда требовать подпись - initData = c.Query("_auth") + authData = c.Query("_auth") } - if initData == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Авторизация отклонена: отсутствует подпись Telegram"}) + if authData == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Авторизация отклонена: отсутствует токен или подпись"}) return } - // 2. Проверяем подпись (HMAC-SHA256) - isValid, err := verifyTelegramInitData(initData, botToken) + // 2. Попытка 1: Проверяем JWT токен (Desktop) + token, err := jwt.ParseWithClaims(authData, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("неожиданный метод подписи: %v", token.Header["alg"]) + } + return []byte(secretKey), nil + }) + + if err == nil && token.Valid { + // JWT токен валиден + if claims, ok := token.Claims.(*Claims); ok { + // Проверка режима обслуживания: если включен, разрешаем доступ только разработчикам + if maintenanceMode { + isDev := false + for _, devID := range devIDs { + if claims.TelegramID == devID { + isDev = true + break + } + } + if !isDev { + c.AbortWithStatusJSON(503, gin.H{"error": "maintenance_mode", "message": "Сервис на обслуживании"}) + return + } + } + + c.Set("userID", claims.UserID) + c.Set("telegramID", claims.TelegramID) + c.Next() + return + } + } + + // 3. Попытка 2: Проверяем Telegram InitData + isValid, err := verifyTelegramInitData(authData, botToken) if !isValid || err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Критическая ошибка безопасности: поддельная подпись"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Критическая ошибка безопасности: недействительный токен или подпись"}) return } - // 3. Извлекаем User ID из проверенных данных - values, _ := url.ParseQuery(initData) + // Извлекаем User ID из проверенных данных + values, _ := url.ParseQuery(authData) userJSON := values.Get("user") // Извлекаем id вручную из JSON-подобной строки или через простой парсинг @@ -70,7 +112,7 @@ func AuthMiddleware(accountRepo account.Repository, botToken string, maintenance } } - // 4. Ищем пользователя в БД + // Ищем пользователя в БД user, err := accountRepo.GetUserByTelegramID(tgID) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Пользователь не зарегистрирован. Начните диалог с ботом."}) diff --git a/internal/transport/telegram/bot.go b/internal/transport/telegram/bot.go index c2a97ed..5fc65bf 100644 --- a/internal/transport/telegram/bot.go +++ b/internal/transport/telegram/bot.go @@ -19,6 +19,7 @@ import ( "rmser/config" "rmser/internal/domain/account" "rmser/internal/infrastructure/rms" + "rmser/internal/services/auth" "rmser/internal/services/billing" draftsService "rmser/internal/services/drafts" "rmser/internal/services/ocr" @@ -47,6 +48,7 @@ type Bot struct { rmsFactory *rms.Factory cryptoManager *crypto.CryptoManager draftsService *draftsService.Service + authService *auth.Service draftEditor *DraftEditor fsm *StateManager @@ -69,6 +71,7 @@ func NewBot( rmsFactory *rms.Factory, cryptoManager *crypto.CryptoManager, draftsService *draftsService.Service, + authService *auth.Service, maintenanceMode bool, devIDs []int64, ) (*Bot, error) { @@ -105,6 +108,7 @@ func NewBot( rmsFactory: rmsFactory, cryptoManager: cryptoManager, draftsService: draftsService, + authService: authService, fsm: NewStateManager(), adminIDs: admins, devIDs: devs, @@ -198,6 +202,29 @@ func (bot *Bot) registrationMiddleware(next tele.HandlerFunc) tele.HandlerFunc { func (bot *Bot) handleStartCommand(c tele.Context) error { payload := c.Message().Payload + + // Обработка desktop авторизации + if payload != "" && strings.HasPrefix(payload, "auth_") { + sessionID := strings.TrimPrefix(payload, "auth_") + telegramID := c.Sender().ID + + logger.Log.Info("Обработка desktop авторизации", + zap.String("session_id", sessionID), + zap.Int64("telegram_id", telegramID), + ) + + if err := bot.authService.ConfirmDesktopAuth(sessionID, telegramID); err != nil { + logger.Log.Error("Ошибка подтверждения desktop авторизации", + zap.String("session_id", sessionID), + zap.Int64("telegram_id", telegramID), + zap.Error(err), + ) + return c.Send("❌ Ошибка авторизации. Попробуйте снова.", tele.ModeHTML) + } + + return c.Send("✅ Авторизация успешна! Вы можете вернуться в приложение.", tele.ModeHTML) + } + if payload != "" && strings.HasPrefix(payload, "invite_") { return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_")) } diff --git a/internal/transport/ws/server.go b/internal/transport/ws/server.go new file mode 100644 index 0000000..65a5714 --- /dev/null +++ b/internal/transport/ws/server.go @@ -0,0 +1,129 @@ +package ws + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "go.uber.org/zap" + "rmser/internal/domain/account" + "rmser/pkg/logger" +) + +type Server struct { + clients map[string]*websocket.Conn // session_id -> conn + register chan *clientParams + unregister chan string + mu sync.RWMutex + upgrader websocket.Upgrader +} + +type clientParams struct { + sessionID string + conn *websocket.Conn +} + +func NewServer() *Server { + return &Server{ + clients: make(map[string]*websocket.Conn), + register: make(chan *clientParams), + unregister: make(chan string), + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, // Allow All CORS + }, + } +} + +func (s *Server) Run() { + for { + select { + case params := <-s.register: + s.mu.Lock() + // Если уже есть соединение с таким ID, закрываем старое + if old, ok := s.clients[params.sessionID]; ok { + old.Close() + } + s.clients[params.sessionID] = params.conn + s.mu.Unlock() + logger.Log.Info("WS Client connected", zap.String("session_id", params.sessionID)) + + case sessionID := <-s.unregister: + s.mu.Lock() + if conn, ok := s.clients[sessionID]; ok { + conn.Close() + delete(s.clients, sessionID) + } + s.mu.Unlock() + logger.Log.Info("WS Client disconnected", zap.String("session_id", sessionID)) + } + } +} + +// HandleConnections обрабатывает входящие WS запросы +func (s *Server) HandleConnections(c *gin.Context) { + sessionID := c.Query("session_id") + if sessionID == "" { + c.Status(http.StatusBadRequest) + return + } + + conn, err := s.upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + logger.Log.Error("WS Upgrade error", zap.Error(err)) + return + } + + s.register <- &clientParams{sessionID: sessionID, conn: conn} + + // Читаем сообщения, чтобы держать соединение открытым и ловить Disconnect + go func() { + defer func() { + s.unregister <- sessionID + }() + for { + if _, _, err := conn.NextReader(); err != nil { + break + } + } + }() +} + +// SendAuthSuccess отправляет токен и закрывает соединение (так как задача выполнена) +func (s *Server) SendAuthSuccess(sessionID string, token string, user account.User) { + s.mu.RLock() + conn, ok := s.clients[sessionID] + s.mu.RUnlock() + + if !ok { + logger.Log.Warn("WS Client not found for auth", zap.String("session_id", sessionID)) + return + } + + resp := map[string]interface{}{ + "event": "auth_success", + "data": map[string]interface{}{ + "token": token, + "user": map[string]interface{}{ + "id": user.ID, + "telegram_id": user.TelegramID, + "username": user.Username, + "first_name": user.FirstName, + "last_name": user.LastName, + "photo_url": user.PhotoURL, + "is_system_admin": user.IsSystemAdmin, + }, + }, + } + + if err := conn.WriteJSON(resp); err != nil { + logger.Log.Error("WS Write error", zap.Error(err)) + } else { + logger.Log.Info("WS Auth sent successfully", zap.String("session_id", sessionID)) + } + + // Даем время на доставку и закрываем + time.Sleep(500 * time.Millisecond) + s.unregister <- sessionID +} diff --git a/rmser-view/nginx.conf b/rmser-view/nginx.conf index bd9c11f..2f04439 100644 --- a/rmser-view/nginx.conf +++ b/rmser-view/nginx.conf @@ -19,4 +19,12 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + + location /socket.io/ { + proxy_pass http://app:8080; # Или как называется твой контейнер бэка + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; +} } \ No newline at end of file diff --git a/rmser-view/package-lock.json b/rmser-view/package-lock.json index 2d60cf1..d27543d 100644 --- a/rmser-view/package-lock.json +++ b/rmser-view/package-lock.json @@ -15,8 +15,10 @@ "axios": "^1.13.2", "clsx": "^2.1.1", "lucide-react": "^0.563.0", + "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-dropzone": "^14.3.8", "react-router-dom": "^7.10.1", "zustand": "^5.0.9" }, @@ -2744,6 +2746,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/axios": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", @@ -3392,6 +3403,18 @@ "node": ">=16.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3718,7 +3741,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -3837,6 +3859,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3939,6 +3973,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4082,6 +4125,23 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4098,6 +4158,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/raf-schd": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", @@ -4126,6 +4195,23 @@ "react": "^19.2.1" } }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -4409,6 +4495,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/rmser-view/package.json b/rmser-view/package.json index c1dc257..c4d0a23 100644 --- a/rmser-view/package.json +++ b/rmser-view/package.json @@ -17,8 +17,10 @@ "axios": "^1.13.2", "clsx": "^2.1.1", "lucide-react": "^0.563.0", + "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-dropzone": "^14.3.8", "react-router-dom": "^7.10.1", "zustand": "^5.0.9" }, diff --git a/rmser-view/project_context.md b/rmser-view/project_context.md index 666512a..a00433a 100644 --- a/rmser-view/project_context.md +++ b/rmser-view/project_context.md @@ -1,6 +1,6 @@ # =================================================================== # Полный контекст React Typescript проекта -# Сгенерировано: 2026-01-28 02:48:31 +# Сгенерировано: 2026-01-28 08:00:40 # =================================================================== Это полный дамп исходного кода React Typescript (Vite) проекта. @@ -120,8 +120,10 @@ export default defineConfig([ "axios": "^1.13.2", "clsx": "^2.1.1", "lucide-react": "^0.563.0", + "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-dropzone": "^14.3.8", "react-router-dom": "^7.10.1", "zustand": "^5.0.9" }, @@ -2849,6 +2851,15 @@ export default defineConfig([ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/axios": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", @@ -3497,6 +3508,18 @@ export default defineConfig([ "node": ">=16.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3823,7 +3846,6 @@ export default defineConfig([ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -3942,6 +3964,18 @@ export default defineConfig([ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4044,6 +4078,15 @@ export default defineConfig([ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4187,6 +4230,23 @@ export default defineConfig([ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4203,6 +4263,15 @@ export default defineConfig([ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/raf-schd": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", @@ -4231,6 +4300,23 @@ export default defineConfig([ "react": "^19.2.1" } }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -4514,6 +4600,12 @@ export default defineConfig([ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4827,8 +4919,10 @@ export default defineConfig([ "axios": "^1.13.2", "clsx": "^2.1.1", "lucide-react": "^0.563.0", + "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-dropzone": "^14.3.8", "react-router-dom": "^7.10.1", "zustand": "^5.0.9" }, @@ -4856,7 +4950,13 @@ export default defineConfig([ ``` import { useEffect, useState } from "react"; -import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { + BrowserRouter, + Routes, + Route, + Navigate, + useLocation, +} from "react-router-dom"; import { Result, Button } from "antd"; import { Providers } from "./components/layout/Providers"; import { AppLayout } from "./components/layout/AppLayout"; @@ -4867,6 +4967,12 @@ import { DraftsList } from "./pages/DraftsList"; import { SettingsPage } from "./pages/SettingsPage"; import { UNAUTHORIZED_EVENT, MAINTENANCE_EVENT } from "./services/api"; import MaintenancePage from "./pages/MaintenancePage"; +import { usePlatform } from "./hooks/usePlatform"; +import { useAuthStore } from "./stores/authStore"; +import { DesktopAuthScreen } from "./pages/desktop/auth/DesktopAuthScreen"; +import { MobileBrowserStub } from "./pages/desktop/auth/MobileBrowserStub"; +import { DesktopLayout } from "./layouts/DesktopLayout/DesktopLayout"; +import { InvoicesDashboard } from "./pages/desktop/dashboard/InvoicesDashboard"; // Компонент-заглушка для внешних браузеров const NotInTelegramScreen = () => ( @@ -4893,14 +4999,32 @@ const NotInTelegramScreen = () => ( ); -function App() { +// Protected Route для десктопной версии +const ProtectedDesktopRoute = ({ children }: { children: React.ReactNode }) => { + const { isAuthenticated } = useAuthStore(); + const location = useLocation(); + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +}; + +// Внутренний компонент с логикой, которая требует контекста роутера +const AppContent = () => { const [isUnauthorized, setIsUnauthorized] = useState(false); const [isMaintenance, setIsMaintenance] = useState(false); const tg = window.Telegram?.WebApp; + const platform = usePlatform(); + const location = useLocation(); // Теперь это безопасно, т.к. мы внутри BrowserRouter // Проверяем, есть ли данные от Telegram const isInTelegram = !!tg?.initData; + // Проверяем, находимся ли мы на десктопном роуте + const isDesktopRoute = location.pathname.startsWith("/web"); + useEffect(() => { const handleUnauthorized = () => setIsUnauthorized(true); const handleMaintenance = () => setIsMaintenance(true); @@ -4908,7 +5032,7 @@ function App() { window.addEventListener(MAINTENANCE_EVENT, handleMaintenance); if (tg) { - tg.expand(); // Расширяем приложение на все окно + tg.expand(); } return () => { @@ -4917,11 +5041,16 @@ function App() { }; }, [tg]); - // Если открыто не в Telegram — блокируем всё - if (!isInTelegram) { + // Если открыто не в Telegram и это не десктопный роут — блокируем всё + if (!isInTelegram && !isDesktopRoute) { return ; } + // Если это десктопный роут и платформа - мобильный браузер + if (isDesktopRoute && platform === "MobileBrowser") { + return ; + } + // Если бэкенд вернул 401 if (isUnauthorized) { return ( @@ -4947,20 +5076,41 @@ function App() { return ; } + return ( + + {/* Мобильные роуты (существующие) */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Десктопные роуты */} + } /> + }> + + + + } + /> + + + ); +}; + +// Главный компонент-обертка +function App() { return ( - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + ); @@ -4970,6 +5120,110 @@ export default App; ``` +# =================================================================== +# Файл: src/components/DragDropZone.tsx +# =================================================================== + +``` +import React from 'react'; +import { useDropzone } from 'react-dropzone'; +import { InboxOutlined } from '@ant-design/icons'; +import { Typography } from 'antd'; + +const { Text } = Typography; + +interface DragDropZoneProps { + onDrop: (files: File[]) => void; + accept?: Record; + maxSize?: number; + maxFiles?: number; + disabled?: boolean; + className?: string; + children?: React.ReactNode; +} + +/** + * Компонент зоны перетаскивания файлов + * Обертка над react-dropzone с Ant Design стилизацией + */ +export const DragDropZone: React.FC = ({ + onDrop, + accept = { + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/vnd.ms-excel': ['.xls'], + 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp'], + }, + maxSize = 10 * 1024 * 1024, // 10MB по умолчанию + maxFiles = 10, + disabled = false, + className = '', + children, +}) => { + const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ + onDrop, + accept, + maxSize, + maxFiles, + disabled, + }); + + const getBorderColor = () => { + if (isDragReject) return '#ff4d4f'; + if (isDragActive) return '#1890ff'; + return '#d9d9d9'; + }; + + const getBackgroundColor = () => { + if (isDragActive) return '#e6f7ff'; + if (disabled) return '#f5f5f5'; + return '#fafafa'; + }; + + return ( +
+ + {children || ( +
+ +
+ {isDragActive ? ( + Отпустите файлы здесь + ) : ( +
+ Перетащите файлы сюда или нажмите для выбора +
+ + Поддерживаются: .xlsx, .xls, изображения (макс. {maxSize / 1024 / 1024}MB) + +
+ )} +
+
+ )} +
+ ); +}; + +``` + # =================================================================== # Файл: src/components/invoices/CreateContainerModal.tsx # =================================================================== @@ -7030,6 +7284,47 @@ export const useOcr = () => { }; ``` +# =================================================================== +# Файл: src/hooks/usePlatform.ts +# =================================================================== + +``` +import { useMemo } from 'react'; + +export type Platform = 'MobileApp' | 'Desktop' | 'MobileBrowser'; + +/** + * Хук для определения текущей платформы + * MobileApp - если есть специфические признаки мобильного приложения + * Desktop - если это десктопный браузер + * MobileBrowser - если это мобильный браузер + */ +export const usePlatform = (): Platform => { + return useMemo(() => { + const userAgent = navigator.userAgent; + + // Проверка на мобильное приложение (специфические признаки) + // Можно добавить дополнительные проверки для конкретных приложений + const isMobileApp = /rmser-app|mobile-app|cordova|phonegap/i.test(userAgent); + + if (isMobileApp) { + return 'MobileApp'; + } + + // Проверка на мобильный браузер + const isMobileBrowser = /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent); + + if (isMobileBrowser) { + return 'MobileBrowser'; + } + + // По умолчанию - десктоп + return 'Desktop'; + }, []); +}; + +``` + # =================================================================== # Файл: src/hooks/useRecommendations.ts # =================================================================== @@ -7049,6 +7344,222 @@ export const useRecommendations = () => { }; ``` +# =================================================================== +# Файл: src/hooks/useWebSocket.ts +# =================================================================== + +``` +import { useEffect, useState, useRef } from 'react'; + +const apiUrl = import.meta.env.VITE_API_URL || ''; + +// Определяем базовый URL для WS (меняем http->ws, https->wss) +const getWsUrl = () => { + let baseUrl = apiUrl; + if (baseUrl.startsWith('/')) { + baseUrl = window.location.origin; + } else if (!baseUrl) { + baseUrl = 'http://localhost:8080'; + } + + // Заменяем протокол + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = baseUrl.replace(/^http(s)?:\/\//, ''); + // Важно: путь /socket.io/ оставлен для совместимости с Nginx конфигом + return `${protocol}//${host}/socket.io/`; +}; + +interface WsEvent { + event: string; + data: unknown; +} + +export const useWebSocket = (sessionId: string | null) => { + const [isConnected, setIsConnected] = useState(false); + const [lastError, setLastError] = useState(null); + const [lastMessage, setLastMessage] = useState(null); + + const wsRef = useRef(null); + + useEffect(() => { + if (!sessionId) return; + + const url = `${getWsUrl()}?session_id=${sessionId}`; + console.log('🔌 Connecting Native WS:', url); + + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + console.log('✅ WS Connected'); + setIsConnected(true); + setLastError(null); + }; + + ws.onclose = (event) => { + console.log('⚠️ WS Closed', event.code, event.reason); + setIsConnected(false); + }; + + 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); + } + }; + + return () => { + console.log('🧹 WS Cleanup'); + ws.close(); + }; + }, [sessionId]); + + return { isConnected, lastError, lastMessage }; +}; + +``` + +# =================================================================== +# Файл: src/layouts/DesktopLayout/DesktopHeader.tsx +# =================================================================== + +``` +import React from 'react'; +import { Layout, Space, Avatar, Dropdown, Button } from 'antd'; +import { UserOutlined, LogoutOutlined } from '@ant-design/icons'; +import { useAuthStore } from '../../stores/authStore'; + +const { Header } = Layout; + +/** + * Header для десктопной версии + * Содержит логотип, заглушку выбора сервера и аватар пользователя + */ +export const DesktopHeader: React.FC = () => { + const { user, logout } = useAuthStore(); + + const handleLogout = () => { + logout(); + window.location.href = '/web'; + }; + + const userMenuItems = [ + { + key: 'logout', + label: 'Выйти', + icon: , + onClick: handleLogout, + }, + ]; + + return ( +
+
+ {/* Логотип */} +
+ RMSer +
+ + {/* Заглушка выбора сервера */} + +
+ + {/* Аватар пользователя */} + + +
+ } /> + {user?.username || 'Пользователь'} +
+
+
+
+ ); +}; + +``` + +# =================================================================== +# Файл: src/layouts/DesktopLayout/DesktopLayout.tsx +# =================================================================== + +``` +import React from 'react'; +import { Layout } from 'antd'; +import { Outlet } from 'react-router-dom'; +import { DesktopHeader } from './DesktopHeader.tsx'; + +const { Content } = Layout; + +/** + * Основной layout для десктопной версии + * Использует Ant Design Layout с фиксированным Header + */ +export const DesktopLayout: React.FC = () => { + return ( + + + +
+ +
+
+
+ ); +}; + +``` + # =================================================================== # Файл: src/main.tsx # =================================================================== @@ -9001,6 +9512,298 @@ export const SettingsPage: React.FC = () => { ``` +# =================================================================== +# Файл: src/pages/desktop/auth/DesktopAuthScreen.tsx +# =================================================================== + +``` +import React, { useEffect, useState } from "react"; +import { Card, Typography, Spin, Alert, message } from "antd"; +import { QRCodeSVG } from "qrcode.react"; +import { useNavigate } from "react-router-dom"; +import { useWebSocket } from "../../../hooks/useWebSocket"; +import { useAuthStore } from "../../../stores/authStore"; +import { api } from "../../../services/api"; + +const { Title, Paragraph } = Typography; + +interface AuthSuccessData { + token: string; + user: { + id: string; + username: string; + email?: string; + role?: string; + }; +} + +/** + * Экран авторизации для десктопной версии + * Отображает 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 { isConnected, lastError, lastMessage } = useWebSocket(sessionId); + + // Инициализация сессии авторизации при маунте + 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(); + }, []); + + // Обработка события успешной авторизации через 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"); + } + }, [lastMessage, setToken, setUser, navigate]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error || lastError) { + return ( +
+ +
+ ); + } + + return ( +
+ + Авторизация + + Отсканируйте QR код в мобильном приложении для входа + + + {qrLink && ( +
+ +
+ )} + +
+

+ Status:{" "} + {isConnected ? ( + Connected + ) : ( + Disconnected + )} +

+

Session: {sessionId}

+ {lastError &&

Error: {lastError}

} +
+
+
+ ); +}; + +``` + +# =================================================================== +# Файл: src/pages/desktop/auth/MobileBrowserStub.tsx +# =================================================================== + +``` +import React from 'react'; +import { Result, Button } from 'antd'; +import { MobileOutlined } from '@ant-design/icons'; + +/** + * Заглушка для мобильных браузеров + * Отображается когда пользователь пытается открыть десктопную версию на мобильном устройстве + */ +export const MobileBrowserStub: React.FC = () => { + const handleRedirect = () => { + window.location.href = '/'; + }; + + return ( +
+ } + title="Десктопная версия недоступна" + subTitle="Пожалуйста, используйте мобильное приложение или откройте сайт на десктопном устройстве" + extra={[ + , + ]} + /> +
+ ); +}; + +``` + +# =================================================================== +# Файл: src/pages/desktop/dashboard/InvoicesDashboard.tsx +# =================================================================== + +``` +import React from "react"; +import { Typography, Card, List, Empty } from "antd"; +import { DragDropZone } from "../../../components/DragDropZone"; + +const { Title } = Typography; + +/** + * Дашборд черновиков для десктопной версии + * Содержит зону для загрузки файлов и список черновиков + */ +export const InvoicesDashboard: React.FC = () => { + const handleDrop = (files: File[]) => { + console.log("Файлы загружены:", files); + // TODO: Добавить логику обработки файлов + }; + + // Заглушка списка черновиков + const mockDrafts = [ + { + id: "1", + title: "Черновик #1", + date: "2024-01-15", + status: "В работе", + }, + { + id: "2", + title: "Черновик #2", + date: "2024-01-14", + status: "Черновик", + }, + ]; + + return ( +
+ Черновики + + {/* Зона для загрузки файлов */} + + + + + {/* Список черновиков (заглушка) */} + + ( + + + + )} + locale={{ + emptyText: ( + + ), + }} + /> + +
+ ); +}; + +``` + # =================================================================== # Файл: src/services/api.ts # =================================================================== @@ -9008,6 +9811,7 @@ export const SettingsPage: React.FC = () => { ``` import axios from 'axios'; import { notification } from 'antd'; +import { useAuthStore } from '../stores/authStore'; import type { CatalogItem, CreateInvoiceRequest, @@ -9038,6 +9842,12 @@ import type { GetPhotosResponse } from './types'; +// Интерфейс для ответа метода инициализации десктопной авторизации +export interface InitDesktopAuthResponse { + session_id: string; + qr_url: string; +} + // Базовый URL export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api'; @@ -9065,20 +9875,30 @@ const apiClient = axios.create({ }, }); -// --- Request Interceptor (Авторизация через initData) --- +// --- Request Interceptor (Авторизация через JWT или initData) --- apiClient.interceptors.request.use((config) => { - const initData = tg?.initData; - - // Если initData пустая — мы не в Telegram. Блокируем запрос. - if (!initData) { - console.error('Запрос заблокирован: приложение запущено вне Telegram.'); - return Promise.reject(new Error('MISSING_TELEGRAM_DATA')); + // Шаг 1: Whitelist - пропускаем запросы к инициализации десктопной авторизации + if (config.url?.endsWith('/auth/init-desktop')) { + return config; } - // Устанавливаем заголовок согласно новым требованиям - config.headers['Authorization'] = `Bearer ${initData}`; - - 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 + const initData = tg?.initData; + if (initData) { + config.headers['Authorization'] = `Bearer ${initData}`; + return config; + } + + // Шаг 4: Block - если нет ни JWT, ни initData, отклоняем запрос + console.error('Запрос заблокирован: отсутствуют данные авторизации.'); + return Promise.reject(new Error('MISSING_AUTH')); }); // --- Response Interceptor (Обработка ошибок и уведомления) --- @@ -9101,8 +9921,8 @@ apiClient.interceptors.response.use( window.dispatchEvent(new Event(MAINTENANCE_EVENT)); } - // Если запрос был отменен нами (нет initData), не выводим стандартную ошибку API - if (error.message === 'MISSING_TELEGRAM_DATA') { + // Если запрос был отменен нами (нет авторизации), не выводим стандартную ошибку API + if (error.message === 'MISSING_AUTH') { return Promise.reject(error); } @@ -9298,6 +10118,13 @@ export const api = { regenerateDraftFromPhoto: async (id: string): Promise => { await apiClient.post(`/photos/${id}/regenerate`); }, + + // --- Десктопная авторизация --- + + initDesktopAuth: async (): Promise => { + const { data } = await apiClient.post('/auth/init-desktop'); + return data; + }, }; @@ -9619,6 +10446,104 @@ export interface GetPhotosResponse { } ``` +# =================================================================== +# Файл: src/stores/authStore.ts +# =================================================================== + +``` +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface User { + id: string; + username: string; + email?: string; + role?: string; +} + +interface AuthState { + token: string | null; + isAuthenticated: boolean; + user: User | null; + setToken: (token: string) => void; + setUser: (user: User) => void; + logout: () => void; +} + +/** + * Хранилище состояния авторизации + * Сохраняет токен и данные пользователя в localStorage + */ +export const useAuthStore = create()( + persist( + (set) => ({ + token: null, + isAuthenticated: false, + user: null, + + setToken: (token: string) => { + set({ token, isAuthenticated: true }); + }, + + setUser: (user: User) => { + set({ user }); + }, + + logout: () => { + set({ token: null, isAuthenticated: false, user: null }); + }, + }), + { + name: 'auth-storage', + partialize: (state) => ({ + token: state.token, + isAuthenticated: state.isAuthenticated, + user: state.user, + }), + } + ) +); + +``` + +# =================================================================== +# Файл: src/stores/uiStore.ts +# =================================================================== + +``` +import { create } from 'zustand'; + +interface UIState { + // Выбранный сервер (заглушка для будущего функционала) + selectedServer: string | null; + sidebarCollapsed: boolean; + setSelectedServer: (server: string | null) => void; + toggleSidebar: () => void; + setSidebarCollapsed: (collapsed: boolean) => void; +} + +/** + * Хранилище UI состояния десктопной версии + */ +export const useUIStore = create((set) => ({ + selectedServer: null, + sidebarCollapsed: false, + + setSelectedServer: (server: string | null) => { + set({ selectedServer: server }); + }, + + toggleSidebar: () => { + set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })); + }, + + setSidebarCollapsed: (collapsed: boolean) => { + set({ sidebarCollapsed: collapsed }); + }, +})); + +``` + # =================================================================== # Файл: src/vite-env.d.ts # =================================================================== diff --git a/rmser-view/src/App.tsx b/rmser-view/src/App.tsx index e780c29..a07a3ea 100644 --- a/rmser-view/src/App.tsx +++ b/rmser-view/src/App.tsx @@ -1,5 +1,11 @@ import { useEffect, useState } from "react"; -import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { + BrowserRouter, + Routes, + Route, + Navigate, + useLocation, +} from "react-router-dom"; import { Result, Button } from "antd"; import { Providers } from "./components/layout/Providers"; import { AppLayout } from "./components/layout/AppLayout"; @@ -10,6 +16,12 @@ import { DraftsList } from "./pages/DraftsList"; import { SettingsPage } from "./pages/SettingsPage"; import { UNAUTHORIZED_EVENT, MAINTENANCE_EVENT } from "./services/api"; import MaintenancePage from "./pages/MaintenancePage"; +import { usePlatform } from "./hooks/usePlatform"; +import { useAuthStore } from "./stores/authStore"; +import { DesktopAuthScreen } from "./pages/desktop/auth/DesktopAuthScreen"; +import { MobileBrowserStub } from "./pages/desktop/auth/MobileBrowserStub"; +import { DesktopLayout } from "./layouts/DesktopLayout/DesktopLayout"; +import { InvoicesDashboard } from "./pages/desktop/dashboard/InvoicesDashboard"; // Компонент-заглушка для внешних браузеров const NotInTelegramScreen = () => ( @@ -36,14 +48,32 @@ const NotInTelegramScreen = () => ( ); -function App() { +// Protected Route для десктопной версии +const ProtectedDesktopRoute = ({ children }: { children: React.ReactNode }) => { + const { isAuthenticated } = useAuthStore(); + const location = useLocation(); + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +}; + +// Внутренний компонент с логикой, которая требует контекста роутера +const AppContent = () => { const [isUnauthorized, setIsUnauthorized] = useState(false); const [isMaintenance, setIsMaintenance] = useState(false); const tg = window.Telegram?.WebApp; + const platform = usePlatform(); + const location = useLocation(); // Теперь это безопасно, т.к. мы внутри BrowserRouter // Проверяем, есть ли данные от Telegram const isInTelegram = !!tg?.initData; + // Проверяем, находимся ли мы на десктопном роуте + const isDesktopRoute = location.pathname.startsWith("/web"); + useEffect(() => { const handleUnauthorized = () => setIsUnauthorized(true); const handleMaintenance = () => setIsMaintenance(true); @@ -51,7 +81,7 @@ function App() { window.addEventListener(MAINTENANCE_EVENT, handleMaintenance); if (tg) { - tg.expand(); // Расширяем приложение на все окно + tg.expand(); } return () => { @@ -60,11 +90,16 @@ function App() { }; }, [tg]); - // Если открыто не в Telegram — блокируем всё - if (!isInTelegram) { + // Если открыто не в Telegram и это не десктопный роут — блокируем всё + if (!isInTelegram && !isDesktopRoute) { return ; } + // Если это десктопный роут и платформа - мобильный браузер + if (isDesktopRoute && platform === "MobileBrowser") { + return ; + } + // Если бэкенд вернул 401 if (isUnauthorized) { return ( @@ -90,20 +125,41 @@ function App() { return ; } + return ( + + {/* Мобильные роуты (существующие) */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Десктопные роуты */} + } /> + }> + + + + } + /> + + + ); +}; + +// Главный компонент-обертка +function App() { return ( - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + ); diff --git a/rmser-view/src/components/DragDropZone.tsx b/rmser-view/src/components/DragDropZone.tsx new file mode 100644 index 0000000..28d1224 --- /dev/null +++ b/rmser-view/src/components/DragDropZone.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { useDropzone } from 'react-dropzone'; +import { InboxOutlined } from '@ant-design/icons'; +import { Typography } from 'antd'; + +const { Text } = Typography; + +interface DragDropZoneProps { + onDrop: (files: File[]) => void; + accept?: Record; + maxSize?: number; + maxFiles?: number; + disabled?: boolean; + className?: string; + children?: React.ReactNode; +} + +/** + * Компонент зоны перетаскивания файлов + * Обертка над react-dropzone с Ant Design стилизацией + */ +export const DragDropZone: React.FC = ({ + onDrop, + accept = { + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/vnd.ms-excel': ['.xls'], + 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp'], + }, + maxSize = 10 * 1024 * 1024, // 10MB по умолчанию + maxFiles = 10, + disabled = false, + className = '', + children, +}) => { + const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ + onDrop, + accept, + maxSize, + maxFiles, + disabled, + }); + + const getBorderColor = () => { + if (isDragReject) return '#ff4d4f'; + if (isDragActive) return '#1890ff'; + return '#d9d9d9'; + }; + + const getBackgroundColor = () => { + if (isDragActive) return '#e6f7ff'; + if (disabled) return '#f5f5f5'; + return '#fafafa'; + }; + + return ( +
+ + {children || ( +
+ +
+ {isDragActive ? ( + Отпустите файлы здесь + ) : ( +
+ Перетащите файлы сюда или нажмите для выбора +
+ + Поддерживаются: .xlsx, .xls, изображения (макс. {maxSize / 1024 / 1024}MB) + +
+ )} +
+
+ )} +
+ ); +}; diff --git a/rmser-view/src/hooks/usePlatform.ts b/rmser-view/src/hooks/usePlatform.ts new file mode 100644 index 0000000..e5deba5 --- /dev/null +++ b/rmser-view/src/hooks/usePlatform.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; + +export type Platform = 'MobileApp' | 'Desktop' | 'MobileBrowser'; + +/** + * Хук для определения текущей платформы + * MobileApp - если есть специфические признаки мобильного приложения + * Desktop - если это десктопный браузер + * MobileBrowser - если это мобильный браузер + */ +export const usePlatform = (): Platform => { + return useMemo(() => { + const userAgent = navigator.userAgent; + + // Проверка на мобильное приложение (специфические признаки) + // Можно добавить дополнительные проверки для конкретных приложений + const isMobileApp = /rmser-app|mobile-app|cordova|phonegap/i.test(userAgent); + + if (isMobileApp) { + return 'MobileApp'; + } + + // Проверка на мобильный браузер + const isMobileBrowser = /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent); + + if (isMobileBrowser) { + return 'MobileBrowser'; + } + + // По умолчанию - десктоп + return 'Desktop'; + }, []); +}; diff --git a/rmser-view/src/hooks/useWebSocket.ts b/rmser-view/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..ec785b4 --- /dev/null +++ b/rmser-view/src/hooks/useWebSocket.ts @@ -0,0 +1,76 @@ +import { useEffect, useState, useRef } from 'react'; + +const apiUrl = import.meta.env.VITE_API_URL || ''; + +// Определяем базовый URL для WS (меняем http->ws, https->wss) +const getWsUrl = () => { + let baseUrl = apiUrl; + if (baseUrl.startsWith('/')) { + baseUrl = window.location.origin; + } else if (!baseUrl) { + baseUrl = 'http://localhost:8080'; + } + + // Заменяем протокол + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = baseUrl.replace(/^http(s)?:\/\//, ''); + // Важно: путь /socket.io/ оставлен для совместимости с Nginx конфигом + return `${protocol}//${host}/socket.io/`; +}; + +interface WsEvent { + event: string; + data: unknown; +} + +export const useWebSocket = (sessionId: string | null) => { + const [isConnected, setIsConnected] = useState(false); + const [lastError, setLastError] = useState(null); + const [lastMessage, setLastMessage] = useState(null); + + const wsRef = useRef(null); + + useEffect(() => { + if (!sessionId) return; + + const url = `${getWsUrl()}?session_id=${sessionId}`; + console.log('🔌 Connecting Native WS:', url); + + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + console.log('✅ WS Connected'); + setIsConnected(true); + setLastError(null); + }; + + ws.onclose = (event) => { + console.log('⚠️ WS Closed', event.code, event.reason); + setIsConnected(false); + }; + + 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); + } + }; + + return () => { + console.log('🧹 WS Cleanup'); + ws.close(); + }; + }, [sessionId]); + + return { isConnected, lastError, lastMessage }; +}; diff --git a/rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx b/rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx new file mode 100644 index 0000000..36ca401 --- /dev/null +++ b/rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { Layout, Space, Avatar, Dropdown, Button } from 'antd'; +import { UserOutlined, LogoutOutlined } from '@ant-design/icons'; +import { useAuthStore } from '../../stores/authStore'; + +const { Header } = Layout; + +/** + * Header для десктопной версии + * Содержит логотип, заглушку выбора сервера и аватар пользователя + */ +export const DesktopHeader: React.FC = () => { + const { user, logout } = useAuthStore(); + + const handleLogout = () => { + logout(); + window.location.href = '/web'; + }; + + const userMenuItems = [ + { + key: 'logout', + label: 'Выйти', + icon: , + onClick: handleLogout, + }, + ]; + + return ( +
+
+ {/* Логотип */} +
+ RMSer +
+ + {/* Заглушка выбора сервера */} + +
+ + {/* Аватар пользователя */} + + +
+ } /> + {user?.username || 'Пользователь'} +
+
+
+
+ ); +}; diff --git a/rmser-view/src/layouts/DesktopLayout/DesktopLayout.tsx b/rmser-view/src/layouts/DesktopLayout/DesktopLayout.tsx new file mode 100644 index 0000000..08eaa01 --- /dev/null +++ b/rmser-view/src/layouts/DesktopLayout/DesktopLayout.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Layout } from 'antd'; +import { Outlet } from 'react-router-dom'; +import { DesktopHeader } from './DesktopHeader.tsx'; + +const { Content } = Layout; + +/** + * Основной layout для десктопной версии + * Использует Ant Design Layout с фиксированным Header + */ +export const DesktopLayout: React.FC = () => { + return ( + + + +
+ +
+
+
+ ); +}; diff --git a/rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx b/rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx new file mode 100644 index 0000000..aa58a2f --- /dev/null +++ b/rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx @@ -0,0 +1,178 @@ +import React, { useEffect, useState } from "react"; +import { Card, Typography, Spin, Alert, message, Button } from "antd"; +import { QRCodeSVG } from "qrcode.react"; +import { SendOutlined } from "@ant-design/icons"; +import { useNavigate } from "react-router-dom"; +import { useWebSocket } from "../../../hooks/useWebSocket"; +import { useAuthStore } from "../../../stores/authStore"; +import { api } from "../../../services/api"; + +const { Title, Paragraph } = Typography; + +interface AuthSuccessData { + token: string; + user: { + id: string; + username: string; + email?: string; + role?: string; + }; +} + +/** + * Экран авторизации для десктопной версии + * Отображает 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 { isConnected, lastError, lastMessage } = useWebSocket(sessionId); + + // Инициализация сессии авторизации при маунте + 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(); + }, []); + + // Обработка события успешной авторизации через 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"); + } + }, [lastMessage, setToken, setUser, navigate]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error || lastError) { + return ( +
+ +
+ ); + } + + return ( +
+ + Авторизация + + Отсканируйте QR код для авторизации через Телеграмм + + + {qrLink && ( +
+ +
+ )} + + {qrLink && ( + + )} + +
+

+ Status:{" "} + {isConnected ? ( + Connected + ) : ( + Disconnected + )} +

+

Session: {sessionId}

+ {lastError &&

Error: {lastError}

} +
+
+
+ ); +}; diff --git a/rmser-view/src/pages/desktop/auth/MobileBrowserStub.tsx b/rmser-view/src/pages/desktop/auth/MobileBrowserStub.tsx new file mode 100644 index 0000000..303c0b1 --- /dev/null +++ b/rmser-view/src/pages/desktop/auth/MobileBrowserStub.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Result, Button } from 'antd'; +import { MobileOutlined } from '@ant-design/icons'; + +/** + * Заглушка для мобильных браузеров + * Отображается когда пользователь пытается открыть десктопную версию на мобильном устройстве + */ +export const MobileBrowserStub: React.FC = () => { + const handleRedirect = () => { + window.location.href = '/'; + }; + + return ( +
+ } + title="Десктопная версия недоступна" + subTitle="Пожалуйста, используйте мобильное приложение или откройте сайт на десктопном устройстве" + extra={[ + , + ]} + /> +
+ ); +}; diff --git a/rmser-view/src/pages/desktop/dashboard/InvoicesDashboard.tsx b/rmser-view/src/pages/desktop/dashboard/InvoicesDashboard.tsx new file mode 100644 index 0000000..0cbf0b9 --- /dev/null +++ b/rmser-view/src/pages/desktop/dashboard/InvoicesDashboard.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { Typography, Card, List, Empty } from "antd"; +import { DragDropZone } from "../../../components/DragDropZone"; + +const { Title } = Typography; + +/** + * Дашборд черновиков для десктопной версии + * Содержит зону для загрузки файлов и список черновиков + */ +export const InvoicesDashboard: React.FC = () => { + const handleDrop = (files: File[]) => { + console.log("Файлы загружены:", files); + // TODO: Добавить логику обработки файлов + }; + + // Заглушка списка черновиков + const mockDrafts = [ + { + id: "1", + title: "Черновик #1", + date: "2024-01-15", + status: "В работе", + }, + { + id: "2", + title: "Черновик #2", + date: "2024-01-14", + status: "Черновик", + }, + ]; + + return ( +
+ Черновики + + {/* Зона для загрузки файлов */} + + + + + {/* Список черновиков (заглушка) */} + + ( + + + + )} + locale={{ + emptyText: ( + + ), + }} + /> + +
+ ); +}; diff --git a/rmser-view/src/services/api.ts b/rmser-view/src/services/api.ts index 1364119..c95c2a4 100644 --- a/rmser-view/src/services/api.ts +++ b/rmser-view/src/services/api.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import { notification } from 'antd'; +import { useAuthStore } from '../stores/authStore'; import type { CatalogItem, CreateInvoiceRequest, @@ -30,6 +31,12 @@ import type { GetPhotosResponse } from './types'; +// Интерфейс для ответа метода инициализации десктопной авторизации +export interface InitDesktopAuthResponse { + session_id: string; + qr_url: string; +} + // Базовый URL export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api'; @@ -57,20 +64,30 @@ const apiClient = axios.create({ }, }); -// --- Request Interceptor (Авторизация через initData) --- +// --- Request Interceptor (Авторизация через JWT или initData) --- apiClient.interceptors.request.use((config) => { - const initData = tg?.initData; - - // Если initData пустая — мы не в Telegram. Блокируем запрос. - if (!initData) { - console.error('Запрос заблокирован: приложение запущено вне Telegram.'); - return Promise.reject(new Error('MISSING_TELEGRAM_DATA')); + // Шаг 1: Whitelist - пропускаем запросы к инициализации десктопной авторизации + if (config.url?.endsWith('/auth/init-desktop')) { + return config; } - // Устанавливаем заголовок согласно новым требованиям - config.headers['Authorization'] = `Bearer ${initData}`; - - 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 + const initData = tg?.initData; + if (initData) { + config.headers['Authorization'] = `Bearer ${initData}`; + return config; + } + + // Шаг 4: Block - если нет ни JWT, ни initData, отклоняем запрос + console.error('Запрос заблокирован: отсутствуют данные авторизации.'); + return Promise.reject(new Error('MISSING_AUTH')); }); // --- Response Interceptor (Обработка ошибок и уведомления) --- @@ -93,8 +110,8 @@ apiClient.interceptors.response.use( window.dispatchEvent(new Event(MAINTENANCE_EVENT)); } - // Если запрос был отменен нами (нет initData), не выводим стандартную ошибку API - if (error.message === 'MISSING_TELEGRAM_DATA') { + // Если запрос был отменен нами (нет авторизации), не выводим стандартную ошибку API + if (error.message === 'MISSING_AUTH') { return Promise.reject(error); } @@ -290,5 +307,12 @@ export const api = { regenerateDraftFromPhoto: async (id: string): Promise => { await apiClient.post(`/photos/${id}/regenerate`); }, + + // --- Десктопная авторизация --- + + initDesktopAuth: async (): Promise => { + const { data } = await apiClient.post('/auth/init-desktop'); + return data; + }, }; diff --git a/rmser-view/src/stores/authStore.ts b/rmser-view/src/stores/authStore.ts new file mode 100644 index 0000000..21fb167 --- /dev/null +++ b/rmser-view/src/stores/authStore.ts @@ -0,0 +1,52 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface User { + id: string; + username: string; + email?: string; + role?: string; +} + +interface AuthState { + token: string | null; + isAuthenticated: boolean; + user: User | null; + setToken: (token: string) => void; + setUser: (user: User) => void; + logout: () => void; +} + +/** + * Хранилище состояния авторизации + * Сохраняет токен и данные пользователя в localStorage + */ +export const useAuthStore = create()( + persist( + (set) => ({ + token: null, + isAuthenticated: false, + user: null, + + setToken: (token: string) => { + set({ token, isAuthenticated: true }); + }, + + setUser: (user: User) => { + set({ user }); + }, + + logout: () => { + set({ token: null, isAuthenticated: false, user: null }); + }, + }), + { + name: 'auth-storage', + partialize: (state) => ({ + token: state.token, + isAuthenticated: state.isAuthenticated, + user: state.user, + }), + } + ) +); diff --git a/rmser-view/src/stores/uiStore.ts b/rmser-view/src/stores/uiStore.ts new file mode 100644 index 0000000..196ecd0 --- /dev/null +++ b/rmser-view/src/stores/uiStore.ts @@ -0,0 +1,30 @@ +import { create } from 'zustand'; + +interface UIState { + // Выбранный сервер (заглушка для будущего функционала) + selectedServer: string | null; + sidebarCollapsed: boolean; + setSelectedServer: (server: string | null) => void; + toggleSidebar: () => void; + setSidebarCollapsed: (collapsed: boolean) => void; +} + +/** + * Хранилище UI состояния десктопной версии + */ +export const useUIStore = create((set) => ({ + selectedServer: null, + sidebarCollapsed: false, + + setSelectedServer: (server: string | null) => { + set({ selectedServer: server }); + }, + + toggleSidebar: () => { + set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })); + }, + + setSidebarCollapsed: (collapsed: boolean) => { + set({ sidebarCollapsed: collapsed }); + }, +}));