2801-есть десктоп-версия. реализован ws для авторизации через тг-бота

This commit is contained in:
2026-01-28 08:12:41 +03:00
parent a536b3ff3c
commit b99e328d35
26 changed files with 2258 additions and 82 deletions

View File

@@ -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)

View File

@@ -23,6 +23,7 @@ security:
telegram:
token: "7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4"
bot_username: "resto_transport_bot"
admin_ids: [665599275]
web_app_url: "https://rmser.serty.top"

View File

@@ -50,6 +50,7 @@ type OCRConfig struct {
type TelegramConfig struct {
Token string `mapstructure:"token"`
BotUsername string `mapstructure:"bot_username"`
AdminIDs []int64 `mapstructure:"admin_ids"`
WebAppURL string `mapstructure:"web_app_url"`
}

2
go.mod
View File

@@ -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

4
go.sum
View File

@@ -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=

View 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
}

View 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,
})
}

View File

@@ -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": "Пользователь не зарегистрирован. Начните диалог с ботом."})

View File

@@ -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_"))
}

View 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
}

View File

@@ -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;
}
}

View File

@@ -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",

View File

@@ -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"
},

File diff suppressed because it is too large Load Diff

View File

@@ -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 = () => (
</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 [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 <NotInTelegramScreen />;
}
// Если это десктопный роут и платформа - мобильный браузер
if (isDesktopRoute && platform === "MobileBrowser") {
return <MobileBrowserStub />;
}
// Если бэкенд вернул 401
if (isUnauthorized) {
return (
@@ -91,9 +126,8 @@ function App() {
}
return (
<Providers>
<BrowserRouter>
<Routes>
{/* Мобильные роуты (существующие) */}
<Route path="/" element={<AppLayout />}>
<Route index element={<Navigate to="/invoices" replace />} />
<Route path="ocr" element={<OcrLearning />} />
@@ -103,7 +137,29 @@ function App() {
<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 (
<Providers>
<BrowserRouter>
<AppContent />
</BrowserRouter>
</Providers>
);

View 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>
);
};

View 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';
}, []);
};

View 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 };
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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}`;
// Шаг 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<void> => {
await apiClient.post(`/photos/${id}/regenerate`);
},
// --- Десктопная авторизация ---
initDesktopAuth: async (): Promise<InitDesktopAuthResponse> => {
const { data } = await apiClient.post<InitDesktopAuthResponse>('/auth/init-desktop');
return data;
},
};

View 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,
}),
}
)
);

View 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 });
},
}));