mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
2801-есть десктоп-версия. реализован ws для авторизации через тг-бота
This commit is contained in:
31
cmd/main.go
31
cmd/main.go
@@ -15,7 +15,9 @@ import (
|
|||||||
"rmser/internal/infrastructure/ocr_client"
|
"rmser/internal/infrastructure/ocr_client"
|
||||||
"rmser/internal/infrastructure/yookassa"
|
"rmser/internal/infrastructure/yookassa"
|
||||||
|
|
||||||
|
"rmser/internal/services/auth"
|
||||||
"rmser/internal/transport/http/middleware"
|
"rmser/internal/transport/http/middleware"
|
||||||
|
"rmser/internal/transport/ws"
|
||||||
tgBot "rmser/internal/transport/telegram"
|
tgBot "rmser/internal/transport/telegram"
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
@@ -56,6 +58,11 @@ func main() {
|
|||||||
log.Fatalf("Ошибка загрузки конфига: %v", err)
|
log.Fatalf("Ошибка загрузки конфига: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем, что bot_username задан в конфиге
|
||||||
|
if cfg.Telegram.BotUsername == "" {
|
||||||
|
log.Fatalf("Telegram.BotUsername не задан в конфиге! Это обязательное поле для авторизации.")
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Logger
|
// 2. Logger
|
||||||
logger.Init(cfg.App.Mode)
|
logger.Init(cfg.App.Mode)
|
||||||
defer logger.Log.Sync()
|
defer logger.Log.Sync()
|
||||||
@@ -102,7 +109,14 @@ func main() {
|
|||||||
invoicesService := invoicesServicePkg.NewService(invoicesRepo, draftsRepo, supplierRepo, rmsFactory)
|
invoicesService := invoicesServicePkg.NewService(invoicesRepo, draftsRepo, supplierRepo, rmsFactory)
|
||||||
photosService := photosServicePkg.NewService(photosRepo, draftsRepo, accountRepo)
|
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)
|
draftsHandler := handlers.NewDraftsHandler(draftsService)
|
||||||
billingHandler := handlers.NewBillingHandler(billingService)
|
billingHandler := handlers.NewBillingHandler(billingService)
|
||||||
ocrHandler := handlers.NewOCRHandler(ocrService)
|
ocrHandler := handlers.NewOCRHandler(ocrService)
|
||||||
@@ -110,10 +124,11 @@ func main() {
|
|||||||
recommendHandler := handlers.NewRecommendationsHandler(recService)
|
recommendHandler := handlers.NewRecommendationsHandler(recService)
|
||||||
settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)
|
settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)
|
||||||
invoicesHandler := handlers.NewInvoiceHandler(invoicesService, syncService)
|
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 != "" {
|
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 {
|
if err != nil {
|
||||||
logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err))
|
logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -125,12 +140,15 @@ func main() {
|
|||||||
defer bot.Stop()
|
defer bot.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. HTTP Server
|
// 11. HTTP Server
|
||||||
if cfg.App.Mode == "release" {
|
if cfg.App.Mode == "release" {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
}
|
}
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
|
// Регистрируем WebSocket хендлер
|
||||||
|
r.GET("/socket.io/", wsServer.HandleConnections)
|
||||||
|
|
||||||
r.POST("/api/webhooks/yookassa", billingHandler.YooKassaWebhook)
|
r.POST("/api/webhooks/yookassa", billingHandler.YooKassaWebhook)
|
||||||
|
|
||||||
corsConfig := cors.DefaultConfig()
|
corsConfig := cors.DefaultConfig()
|
||||||
@@ -145,7 +163,10 @@ func main() {
|
|||||||
|
|
||||||
api := r.Group("/api")
|
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
|
// Drafts & Invoices
|
||||||
api.GET("/drafts", draftsHandler.GetDrafts)
|
api.GET("/drafts", draftsHandler.GetDrafts)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ security:
|
|||||||
|
|
||||||
telegram:
|
telegram:
|
||||||
token: "7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4"
|
token: "7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4"
|
||||||
|
bot_username: "resto_transport_bot"
|
||||||
admin_ids: [665599275]
|
admin_ids: [665599275]
|
||||||
web_app_url: "https://rmser.serty.top"
|
web_app_url: "https://rmser.serty.top"
|
||||||
|
|
||||||
|
|||||||
@@ -49,9 +49,10 @@ type OCRConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TelegramConfig struct {
|
type TelegramConfig struct {
|
||||||
Token string `mapstructure:"token"`
|
Token string `mapstructure:"token"`
|
||||||
AdminIDs []int64 `mapstructure:"admin_ids"`
|
BotUsername string `mapstructure:"bot_username"`
|
||||||
WebAppURL string `mapstructure:"web_app_url"`
|
AdminIDs []int64 `mapstructure:"admin_ids"`
|
||||||
|
WebAppURL string `mapstructure:"web_app_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SecurityConfig struct {
|
type SecurityConfig struct {
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -5,7 +5,9 @@ go 1.25.5
|
|||||||
require (
|
require (
|
||||||
github.com/gin-contrib/cors v1.7.6
|
github.com/gin-contrib/cors v1.7.6
|
||||||
github.com/gin-gonic/gin v1.11.0
|
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/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/websocket v1.4.2
|
||||||
github.com/jackc/pgx/v5 v5.6.0
|
github.com/jackc/pgx/v5 v5.6.0
|
||||||
github.com/redis/go-redis/v9 v9.17.1
|
github.com/redis/go-redis/v9 v9.17.1
|
||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.4.0
|
||||||
|
|||||||
4
go.sum
4
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/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.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
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/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-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/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.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
|
||||||
github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c=
|
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/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/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/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=
|
github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0=
|
||||||
|
|||||||
107
internal/services/auth/service.go
Normal file
107
internal/services/auth/service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
51
internal/transport/http/handlers/auth.go
Normal file
51
internal/transport/http/handlers/auth.go
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -14,37 +14,79 @@ import (
|
|||||||
"rmser/internal/domain/account"
|
"rmser/internal/domain/account"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthMiddleware проверяет initData от Telegram
|
// Claims представляет JWT claims для токена авторизации
|
||||||
func AuthMiddleware(accountRepo account.Repository, botToken string, maintenanceMode bool, devIDs []int64) gin.HandlerFunc {
|
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) {
|
return func(c *gin.Context) {
|
||||||
// 1. Извлекаем данные авторизации
|
// 1. Извлекаем данные авторизации
|
||||||
authHeader := c.GetHeader("Authorization")
|
authHeader := c.GetHeader("Authorization")
|
||||||
var initData string
|
var authData string
|
||||||
|
|
||||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
initData = strings.TrimPrefix(authHeader, "Bearer ")
|
authData = strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
} else {
|
} else {
|
||||||
// Оставляем лазейку для отладки ТОЛЬКО если это не production режим
|
// Оставляем лазейку для отладки ТОЛЬКО если это не production режим
|
||||||
// В реальности лучше всегда требовать подпись
|
// В реальности лучше всегда требовать подпись
|
||||||
initData = c.Query("_auth")
|
authData = c.Query("_auth")
|
||||||
}
|
}
|
||||||
|
|
||||||
if initData == "" {
|
if authData == "" {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Авторизация отклонена: отсутствует подпись Telegram"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Авторизация отклонена: отсутствует токен или подпись"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Проверяем подпись (HMAC-SHA256)
|
// 2. Попытка 1: Проверяем JWT токен (Desktop)
|
||||||
isValid, err := verifyTelegramInitData(initData, botToken)
|
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 {
|
if !isValid || err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Критическая ошибка безопасности: поддельная подпись"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Критическая ошибка безопасности: недействительный токен или подпись"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Извлекаем User ID из проверенных данных
|
// Извлекаем User ID из проверенных данных
|
||||||
values, _ := url.ParseQuery(initData)
|
values, _ := url.ParseQuery(authData)
|
||||||
userJSON := values.Get("user")
|
userJSON := values.Get("user")
|
||||||
|
|
||||||
// Извлекаем id вручную из JSON-подобной строки или через простой парсинг
|
// Извлекаем id вручную из JSON-подобной строки или через простой парсинг
|
||||||
@@ -70,7 +112,7 @@ func AuthMiddleware(accountRepo account.Repository, botToken string, maintenance
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Ищем пользователя в БД
|
// Ищем пользователя в БД
|
||||||
user, err := accountRepo.GetUserByTelegramID(tgID)
|
user, err := accountRepo.GetUserByTelegramID(tgID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Пользователь не зарегистрирован. Начните диалог с ботом."})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Пользователь не зарегистрирован. Начните диалог с ботом."})
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"rmser/config"
|
"rmser/config"
|
||||||
"rmser/internal/domain/account"
|
"rmser/internal/domain/account"
|
||||||
"rmser/internal/infrastructure/rms"
|
"rmser/internal/infrastructure/rms"
|
||||||
|
"rmser/internal/services/auth"
|
||||||
"rmser/internal/services/billing"
|
"rmser/internal/services/billing"
|
||||||
draftsService "rmser/internal/services/drafts"
|
draftsService "rmser/internal/services/drafts"
|
||||||
"rmser/internal/services/ocr"
|
"rmser/internal/services/ocr"
|
||||||
@@ -47,6 +48,7 @@ type Bot struct {
|
|||||||
rmsFactory *rms.Factory
|
rmsFactory *rms.Factory
|
||||||
cryptoManager *crypto.CryptoManager
|
cryptoManager *crypto.CryptoManager
|
||||||
draftsService *draftsService.Service
|
draftsService *draftsService.Service
|
||||||
|
authService *auth.Service
|
||||||
draftEditor *DraftEditor
|
draftEditor *DraftEditor
|
||||||
|
|
||||||
fsm *StateManager
|
fsm *StateManager
|
||||||
@@ -69,6 +71,7 @@ func NewBot(
|
|||||||
rmsFactory *rms.Factory,
|
rmsFactory *rms.Factory,
|
||||||
cryptoManager *crypto.CryptoManager,
|
cryptoManager *crypto.CryptoManager,
|
||||||
draftsService *draftsService.Service,
|
draftsService *draftsService.Service,
|
||||||
|
authService *auth.Service,
|
||||||
maintenanceMode bool,
|
maintenanceMode bool,
|
||||||
devIDs []int64,
|
devIDs []int64,
|
||||||
) (*Bot, error) {
|
) (*Bot, error) {
|
||||||
@@ -105,6 +108,7 @@ func NewBot(
|
|||||||
rmsFactory: rmsFactory,
|
rmsFactory: rmsFactory,
|
||||||
cryptoManager: cryptoManager,
|
cryptoManager: cryptoManager,
|
||||||
draftsService: draftsService,
|
draftsService: draftsService,
|
||||||
|
authService: authService,
|
||||||
fsm: NewStateManager(),
|
fsm: NewStateManager(),
|
||||||
adminIDs: admins,
|
adminIDs: admins,
|
||||||
devIDs: devs,
|
devIDs: devs,
|
||||||
@@ -198,6 +202,29 @@ func (bot *Bot) registrationMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
|
|||||||
|
|
||||||
func (bot *Bot) handleStartCommand(c tele.Context) error {
|
func (bot *Bot) handleStartCommand(c tele.Context) error {
|
||||||
payload := c.Message().Payload
|
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_") {
|
if payload != "" && strings.HasPrefix(payload, "invite_") {
|
||||||
return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_"))
|
return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_"))
|
||||||
}
|
}
|
||||||
|
|||||||
129
internal/transport/ws/server.go
Normal file
129
internal/transport/ws/server.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -19,4 +19,12 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
94
rmser-view/package-lock.json
generated
94
rmser-view/package-lock.json
generated
@@ -15,8 +15,10 @@
|
|||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-dropzone": "^14.3.8",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
@@ -2744,6 +2746,15 @@
|
|||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.2",
|
"version": "1.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||||
@@ -3392,6 +3403,18 @@
|
|||||||
"node": ">=16.0.0"
|
"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": {
|
"node_modules/find-up": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||||
@@ -3718,7 +3741,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -3837,6 +3859,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -3939,6 +3973,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -4082,6 +4125,23 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
@@ -4098,6 +4158,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/raf-schd": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||||
@@ -4126,6 +4195,23 @@
|
|||||||
"react": "^19.2.1"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
@@ -4409,6 +4495,12 @@
|
|||||||
"typescript": ">=4.8.4"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -17,8 +17,10 @@
|
|||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-dropzone": "^14.3.8",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { Result, Button } from "antd";
|
||||||
import { Providers } from "./components/layout/Providers";
|
import { Providers } from "./components/layout/Providers";
|
||||||
import { AppLayout } from "./components/layout/AppLayout";
|
import { AppLayout } from "./components/layout/AppLayout";
|
||||||
@@ -10,6 +16,12 @@ import { DraftsList } from "./pages/DraftsList";
|
|||||||
import { SettingsPage } from "./pages/SettingsPage";
|
import { SettingsPage } from "./pages/SettingsPage";
|
||||||
import { UNAUTHORIZED_EVENT, MAINTENANCE_EVENT } from "./services/api";
|
import { UNAUTHORIZED_EVENT, MAINTENANCE_EVENT } from "./services/api";
|
||||||
import MaintenancePage from "./pages/MaintenancePage";
|
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 = () => (
|
const NotInTelegramScreen = () => (
|
||||||
@@ -36,14 +48,32 @@ const NotInTelegramScreen = () => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
function App() {
|
// Protected Route для десктопной версии
|
||||||
|
const ProtectedDesktopRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/web" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Внутренний компонент с логикой, которая требует контекста роутера
|
||||||
|
const AppContent = () => {
|
||||||
const [isUnauthorized, setIsUnauthorized] = useState(false);
|
const [isUnauthorized, setIsUnauthorized] = useState(false);
|
||||||
const [isMaintenance, setIsMaintenance] = useState(false);
|
const [isMaintenance, setIsMaintenance] = useState(false);
|
||||||
const tg = window.Telegram?.WebApp;
|
const tg = window.Telegram?.WebApp;
|
||||||
|
const platform = usePlatform();
|
||||||
|
const location = useLocation(); // Теперь это безопасно, т.к. мы внутри BrowserRouter
|
||||||
|
|
||||||
// Проверяем, есть ли данные от Telegram
|
// Проверяем, есть ли данные от Telegram
|
||||||
const isInTelegram = !!tg?.initData;
|
const isInTelegram = !!tg?.initData;
|
||||||
|
|
||||||
|
// Проверяем, находимся ли мы на десктопном роуте
|
||||||
|
const isDesktopRoute = location.pathname.startsWith("/web");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleUnauthorized = () => setIsUnauthorized(true);
|
const handleUnauthorized = () => setIsUnauthorized(true);
|
||||||
const handleMaintenance = () => setIsMaintenance(true);
|
const handleMaintenance = () => setIsMaintenance(true);
|
||||||
@@ -51,7 +81,7 @@ function App() {
|
|||||||
window.addEventListener(MAINTENANCE_EVENT, handleMaintenance);
|
window.addEventListener(MAINTENANCE_EVENT, handleMaintenance);
|
||||||
|
|
||||||
if (tg) {
|
if (tg) {
|
||||||
tg.expand(); // Расширяем приложение на все окно
|
tg.expand();
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -60,11 +90,16 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [tg]);
|
}, [tg]);
|
||||||
|
|
||||||
// Если открыто не в Telegram — блокируем всё
|
// Если открыто не в Telegram и это не десктопный роут — блокируем всё
|
||||||
if (!isInTelegram) {
|
if (!isInTelegram && !isDesktopRoute) {
|
||||||
return <NotInTelegramScreen />;
|
return <NotInTelegramScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если это десктопный роут и платформа - мобильный браузер
|
||||||
|
if (isDesktopRoute && platform === "MobileBrowser") {
|
||||||
|
return <MobileBrowserStub />;
|
||||||
|
}
|
||||||
|
|
||||||
// Если бэкенд вернул 401
|
// Если бэкенд вернул 401
|
||||||
if (isUnauthorized) {
|
if (isUnauthorized) {
|
||||||
return (
|
return (
|
||||||
@@ -90,20 +125,41 @@ function App() {
|
|||||||
return <MaintenancePage />;
|
return <MaintenancePage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Мобильные роуты (существующие) */}
|
||||||
|
<Route path="/" element={<AppLayout />}>
|
||||||
|
<Route index element={<Navigate to="/invoices" replace />} />
|
||||||
|
<Route path="ocr" element={<OcrLearning />} />
|
||||||
|
<Route path="invoices" element={<DraftsList />} />
|
||||||
|
<Route path="invoice/draft/:id" element={<InvoiceDraftPage />} />
|
||||||
|
<Route path="invoice/view/:id" element={<InvoiceViewPage />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Десктопные роуты */}
|
||||||
|
<Route path="/web" element={<DesktopAuthScreen />} />
|
||||||
|
<Route path="/web" element={<DesktopLayout />}>
|
||||||
|
<Route
|
||||||
|
path="dashboard"
|
||||||
|
element={
|
||||||
|
<ProtectedDesktopRoute>
|
||||||
|
<InvoicesDashboard />
|
||||||
|
</ProtectedDesktopRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Главный компонент-обертка
|
||||||
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Providers>
|
<Providers>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<AppContent />
|
||||||
<Route path="/" element={<AppLayout />}>
|
|
||||||
<Route index element={<Navigate to="/invoices" replace />} />
|
|
||||||
<Route path="ocr" element={<OcrLearning />} />
|
|
||||||
<Route path="invoices" element={<DraftsList />} />
|
|
||||||
<Route path="invoice/draft/:id" element={<InvoiceDraftPage />} />
|
|
||||||
<Route path="invoice/view/:id" element={<InvoiceViewPage />} />
|
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</Providers>
|
</Providers>
|
||||||
);
|
);
|
||||||
|
|||||||
96
rmser-view/src/components/DragDropZone.tsx
Normal file
96
rmser-view/src/components/DragDropZone.tsx
Normal file
@@ -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<string, string[]>;
|
||||||
|
maxSize?: number;
|
||||||
|
maxFiles?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компонент зоны перетаскивания файлов
|
||||||
|
* Обертка над react-dropzone с Ant Design стилизацией
|
||||||
|
*/
|
||||||
|
export const DragDropZone: React.FC<DragDropZoneProps> = ({
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
border: `2px dashed ${getBorderColor()}`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '40px 20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
backgroundColor: getBackgroundColor(),
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
{children || (
|
||||||
|
<div>
|
||||||
|
<InboxOutlined
|
||||||
|
style={{
|
||||||
|
fontSize: '48px',
|
||||||
|
color: isDragActive ? '#1890ff' : '#bfbfbf',
|
||||||
|
marginBottom: '16px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
{isDragActive ? (
|
||||||
|
<Text type="secondary">Отпустите файлы здесь</Text>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Text>Перетащите файлы сюда или нажмите для выбора</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
Поддерживаются: .xlsx, .xls, изображения (макс. {maxSize / 1024 / 1024}MB)
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
33
rmser-view/src/hooks/usePlatform.ts
Normal file
33
rmser-view/src/hooks/usePlatform.ts
Normal file
@@ -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';
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
76
rmser-view/src/hooks/useWebSocket.ts
Normal file
76
rmser-view/src/hooks/useWebSocket.ts
Normal file
@@ -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<string | null>(null);
|
||||||
|
const [lastMessage, setLastMessage] = useState<WsEvent | null>(null);
|
||||||
|
|
||||||
|
const wsRef = useRef<WebSocket | null>(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 };
|
||||||
|
};
|
||||||
86
rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx
Normal file
86
rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx
Normal file
@@ -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: <LogoutOutlined />,
|
||||||
|
onClick: handleLogout,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||||
|
padding: '0 24px',
|
||||||
|
height: '64px',
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
|
||||||
|
{/* Логотип */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1890ff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>RMSer</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Заглушка выбора сервера */}
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
ghost
|
||||||
|
style={{
|
||||||
|
color: '#8c8c8c',
|
||||||
|
borderColor: '#d9d9d9',
|
||||||
|
cursor: 'default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сервер не выбран
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Аватар пользователя */}
|
||||||
|
<Space>
|
||||||
|
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||||
|
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Avatar size="default" icon={<UserOutlined />} />
|
||||||
|
<span style={{ color: '#262626' }}>{user?.username || 'Пользователь'}</span>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</Space>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
};
|
||||||
30
rmser-view/src/layouts/DesktopLayout/DesktopLayout.tsx
Normal file
30
rmser-view/src/layouts/DesktopLayout/DesktopLayout.tsx
Normal file
@@ -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 (
|
||||||
|
<Layout style={{ minHeight: '100vh', backgroundColor: '#f0f2f5' }}>
|
||||||
|
<DesktopHeader />
|
||||||
|
<Content style={{ padding: '24px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: 'calc(100vh - 64px - 48px)',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
178
rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx
Normal file
178
rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx
Normal file
@@ -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<string | null>(null);
|
||||||
|
const [qrLink, setQrLink] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
backgroundColor: "#f0f2f5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spin size="large" tip="Загрузка..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || lastError) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
backgroundColor: "#f0f2f5",
|
||||||
|
padding: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
message="Ошибка"
|
||||||
|
description={error || lastError || "Произошла ошибка при подключении"}
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
style={{ maxWidth: "400px" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
backgroundColor: "#f0f2f5",
|
||||||
|
padding: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "400px",
|
||||||
|
textAlign: "center",
|
||||||
|
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Title level={3}>Авторизация</Title>
|
||||||
|
<Paragraph type="secondary">
|
||||||
|
Отсканируйте QR код для авторизации через Телеграмм
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
{qrLink && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: "24px 0",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QRCodeSVG value={qrLink} size={200} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrLink && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
href={qrLink}
|
||||||
|
target="_blank"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
Открыть в Telegram Desktop
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 24,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#888",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Status:{" "}
|
||||||
|
{isConnected ? (
|
||||||
|
<span style={{ color: "green" }}>Connected</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "red" }}>Disconnected</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p>Session: {sessionId}</p>
|
||||||
|
{lastError && <p style={{ color: "red" }}>Error: {lastError}</p>}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
rmser-view/src/pages/desktop/auth/MobileBrowserStub.tsx
Normal file
37
rmser-view/src/pages/desktop/auth/MobileBrowserStub.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
backgroundColor: '#f0f2f5',
|
||||||
|
padding: '24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Result
|
||||||
|
icon={<MobileOutlined style={{ fontSize: '72px', color: '#1890ff' }} />}
|
||||||
|
title="Десктопная версия недоступна"
|
||||||
|
subTitle="Пожалуйста, используйте мобильное приложение или откройте сайт на десктопном устройстве"
|
||||||
|
extra={[
|
||||||
|
<Button type="primary" key="mobile" onClick={handleRedirect}>
|
||||||
|
Перейти к мобильной версии
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
rmser-view/src/pages/desktop/dashboard/InvoicesDashboard.tsx
Normal file
66
rmser-view/src/pages/desktop/dashboard/InvoicesDashboard.tsx
Normal file
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<Title level={2}>Черновики</Title>
|
||||||
|
|
||||||
|
{/* Зона для загрузки файлов */}
|
||||||
|
<Card style={{ marginBottom: "24px" }}>
|
||||||
|
<DragDropZone onDrop={handleDrop} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Список черновиков (заглушка) */}
|
||||||
|
<Card title="Последние черновики">
|
||||||
|
<List
|
||||||
|
dataSource={mockDrafts}
|
||||||
|
renderItem={(draft) => (
|
||||||
|
<List.Item>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={draft.title}
|
||||||
|
description={`${draft.date} • ${draft.status}`}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<Empty
|
||||||
|
description="Нет черновиков"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { notification } from 'antd';
|
import { notification } from 'antd';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import type {
|
import type {
|
||||||
CatalogItem,
|
CatalogItem,
|
||||||
CreateInvoiceRequest,
|
CreateInvoiceRequest,
|
||||||
@@ -30,6 +31,12 @@ import type {
|
|||||||
GetPhotosResponse
|
GetPhotosResponse
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
// Интерфейс для ответа метода инициализации десктопной авторизации
|
||||||
|
export interface InitDesktopAuthResponse {
|
||||||
|
session_id: string;
|
||||||
|
qr_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Базовый URL
|
// Базовый URL
|
||||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
|
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) => {
|
apiClient.interceptors.request.use((config) => {
|
||||||
const initData = tg?.initData;
|
// Шаг 1: Whitelist - пропускаем запросы к инициализации десктопной авторизации
|
||||||
|
if (config.url?.endsWith('/auth/init-desktop')) {
|
||||||
// Если initData пустая — мы не в Telegram. Блокируем запрос.
|
return config;
|
||||||
if (!initData) {
|
|
||||||
console.error('Запрос заблокирован: приложение запущено вне Telegram.');
|
|
||||||
return Promise.reject(new Error('MISSING_TELEGRAM_DATA'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Устанавливаем заголовок согласно новым требованиям
|
// Шаг 2: Desktop Auth - проверяем JWT токен из authStore
|
||||||
config.headers['Authorization'] = `Bearer ${initData}`;
|
const jwtToken = useAuthStore.getState().token;
|
||||||
|
if (jwtToken) {
|
||||||
|
config.headers['Authorization'] = `Bearer ${jwtToken}`;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
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 (Обработка ошибок и уведомления) ---
|
// --- Response Interceptor (Обработка ошибок и уведомления) ---
|
||||||
@@ -93,8 +110,8 @@ apiClient.interceptors.response.use(
|
|||||||
window.dispatchEvent(new Event(MAINTENANCE_EVENT));
|
window.dispatchEvent(new Event(MAINTENANCE_EVENT));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если запрос был отменен нами (нет initData), не выводим стандартную ошибку API
|
// Если запрос был отменен нами (нет авторизации), не выводим стандартную ошибку API
|
||||||
if (error.message === 'MISSING_TELEGRAM_DATA') {
|
if (error.message === 'MISSING_AUTH') {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,5 +307,12 @@ export const api = {
|
|||||||
regenerateDraftFromPhoto: async (id: string): Promise<void> => {
|
regenerateDraftFromPhoto: async (id: string): Promise<void> => {
|
||||||
await apiClient.post(`/photos/${id}/regenerate`);
|
await apiClient.post(`/photos/${id}/regenerate`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- Десктопная авторизация ---
|
||||||
|
|
||||||
|
initDesktopAuth: async (): Promise<InitDesktopAuthResponse> => {
|
||||||
|
const { data } = await apiClient.post<InitDesktopAuthResponse>('/auth/init-desktop');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
52
rmser-view/src/stores/authStore.ts
Normal file
52
rmser-view/src/stores/authStore.ts
Normal file
@@ -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<AuthState>()(
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
30
rmser-view/src/stores/uiStore.ts
Normal file
30
rmser-view/src/stores/uiStore.ts
Normal file
@@ -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<UIState>((set) => ({
|
||||||
|
selectedServer: null,
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
|
||||||
|
setSelectedServer: (server: string | null) => {
|
||||||
|
set({ selectedServer: server });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSidebar: () => {
|
||||||
|
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }));
|
||||||
|
},
|
||||||
|
|
||||||
|
setSidebarCollapsed: (collapsed: boolean) => {
|
||||||
|
set({ sidebarCollapsed: collapsed });
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user