feat: add soft ui auth page

This commit is contained in:
root
2026-02-20 09:31:13 +03:00
parent a4cc4f9de6
commit 8c3e993eb7
15 changed files with 1014 additions and 24 deletions

View File

@@ -14,8 +14,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
# Открываем порт # Открываем порт
EXPOSE 8200 EXPOSE 4200
# Запускаем приложение # Запускаем приложение
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8200"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "4200"]

240
backend/app/api/auth2.py Normal file
View File

@@ -0,0 +1,240 @@
"""
Alternative auth endpoint (tg/max/sms) without touching existing flow.
/api/v1/auth2/login:
- platform=tg|max|sms
- Validates init_data for TG/MAX and calls n8n webhook
- For SMS: verifies code, calls n8n contact webhook, creates session
- Returns greeting message
"""
import logging
from typing import Optional, Literal, Any, Dict
from fastapi import APIRouter, HTTPException
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from ..services.sms_service import sms_service
from ..services.telegram_auth import extract_telegram_user, TelegramAuthError
from ..services.max_auth import extract_max_user, MaxAuthError
from ..config import settings
from . import n8n_proxy
from . import session as session_api
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/auth2", tags=["auth2"])
class Auth2LoginRequest(BaseModel):
platform: Literal["tg", "max", "sms"]
init_data: Optional[str] = None
phone: Optional[str] = None
code: Optional[str] = None
session_token: Optional[str] = None
form_id: str = "ticket_form"
class Auth2LoginResponse(BaseModel):
success: bool
greeting: str
session_token: Optional[str] = None
unified_id: Optional[str] = None
contact_id: Optional[str] = None
phone: Optional[str] = None
has_drafts: Optional[bool] = None
need_contact: Optional[bool] = None
avatar_url: Optional[str] = None
def _generate_session_token() -> str:
import uuid
return f"sess-{uuid.uuid4()}"
@router.post("/login", response_model=Auth2LoginResponse)
async def login(request: Auth2LoginRequest):
platform = request.platform
logger.info("[AUTH2] login: platform=%s", platform)
if platform == "tg":
if not request.init_data:
raise HTTPException(status_code=400, detail="init_data обязателен для tg")
try:
tg_user = extract_telegram_user(request.init_data)
except TelegramAuthError as e:
raise HTTPException(status_code=400, detail=str(e))
session_token = request.session_token or _generate_session_token()
n8n_payload = {
"telegram_user_id": tg_user["telegram_user_id"],
"username": tg_user.get("username"),
"first_name": tg_user.get("first_name"),
"last_name": tg_user.get("last_name"),
"session_token": session_token,
"form_id": request.form_id,
"init_data": request.init_data,
}
class _DummyRequest:
def __init__(self, payload: Dict[str, Any]):
self._payload = payload
async def json(self):
return self._payload
n8n_response = await n8n_proxy.proxy_telegram_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
n8n_data = jsonable_encoder(n8n_response)
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
phone = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
has_drafts = n8n_data.get("has_drafts")
if not unified_id:
raise HTTPException(status_code=500, detail="n8n не вернул unified_id")
await session_api.create_session(session_api.SessionCreateRequest(
session_token=session_token,
unified_id=unified_id,
phone=phone or "",
contact_id=contact_id or "",
ttl_hours=24,
))
first_name = tg_user.get("first_name") or ""
greeting = f"Привет, {first_name}!" if first_name else "Привет!"
return Auth2LoginResponse(
success=True,
greeting=greeting,
session_token=session_token,
unified_id=unified_id,
contact_id=contact_id,
phone=phone,
has_drafts=has_drafts,
avatar_url=tg_user.get("photo_url") or None,
)
if platform == "max":
if not request.init_data:
raise HTTPException(status_code=400, detail="init_data обязателен для max")
try:
max_user = extract_max_user(request.init_data)
except MaxAuthError as e:
raise HTTPException(status_code=400, detail=str(e))
session_token = request.session_token or _generate_session_token()
n8n_payload = {
"max_user_id": max_user["max_user_id"],
"username": max_user.get("username"),
"first_name": max_user.get("first_name"),
"last_name": max_user.get("last_name"),
"session_token": session_token,
"form_id": request.form_id,
"init_data": request.init_data,
}
class _DummyRequest:
def __init__(self, payload: Dict[str, Any]):
self._payload = payload
async def json(self):
return self._payload
n8n_response = await n8n_proxy.proxy_max_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
n8n_data = jsonable_encoder(n8n_response)
need_contact = n8n_data.get("need_contact") or (n8n_data.get("result") or {}).get("need_contact")
if need_contact:
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
phone = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
has_drafts = n8n_data.get("has_drafts")
if not unified_id:
raise HTTPException(status_code=500, detail="n8n не вернул unified_id")
await session_api.create_session(session_api.SessionCreateRequest(
session_token=session_token,
unified_id=unified_id,
phone=phone or "",
contact_id=contact_id or "",
ttl_hours=24,
))
first_name = max_user.get("first_name") or ""
greeting = f"Привет, {first_name}!" if first_name else "Привет!"
return Auth2LoginResponse(
success=True,
greeting=greeting,
session_token=session_token,
unified_id=unified_id,
contact_id=contact_id,
phone=phone,
has_drafts=has_drafts,
avatar_url=max_user.get("photo_url") or None,
)
if platform == "sms":
phone = (request.phone or "").strip()
code = (request.code or "").strip()
if not phone or not code:
raise HTTPException(status_code=400, detail="phone и code обязательны для sms")
is_valid = await sms_service.verify_code(phone, code)
if not is_valid:
raise HTTPException(status_code=400, detail="Неверный код или код истек")
class _DummyRequest:
def __init__(self, payload: Dict[str, Any]):
self._payload = payload
async def json(self):
return self._payload
n8n_payload = {
"phone": phone,
"session_id": request.session_token or "",
"form_id": request.form_id,
}
n8n_response = await n8n_proxy.proxy_create_contact(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
n8n_data = jsonable_encoder(n8n_response)
if isinstance(n8n_data, list) and n8n_data:
n8n_data = n8n_data[0]
if not n8n_data or not isinstance(n8n_data, dict) or not n8n_data.get("success"):
raise HTTPException(status_code=500, detail="Ошибка создания контакта в n8n")
result = n8n_data.get("result") or n8n_data
unified_id = result.get("unified_id")
contact_id = result.get("contact_id")
phone_res = result.get("phone") or phone
has_drafts = result.get("has_drafts")
session_token = result.get("session") or request.session_token or _generate_session_token()
if not unified_id:
raise HTTPException(status_code=500, detail="n8n не вернул unified_id")
await session_api.create_session(session_api.SessionCreateRequest(
session_token=session_token,
unified_id=unified_id,
phone=phone_res or "",
contact_id=contact_id or "",
ttl_hours=24,
))
return Auth2LoginResponse(
success=True,
greeting="Привет!",
session_token=session_token,
unified_id=unified_id,
contact_id=contact_id,
phone=phone_res,
has_drafts=has_drafts,
avatar_url=None,
)
raise HTTPException(status_code=400, detail="Неподдерживаемая платформа")

146
backend/app/api/max_auth.py Normal file
View File

@@ -0,0 +1,146 @@
"""
MAX Mini App (WebApp) auth endpoint.
/api/v1/max/auth:
- Принимает init_data от MAX Bridge (window.WebApp.initData)
- Валидирует init_data и извлекает данные пользователя MAX
- Проксирует max_user_id в n8n для получения unified_id/контакта
- Создаёт сессию в Redis (аналогично Telegram — без SMS)
"""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from ..services.max_auth import extract_max_user, MaxAuthError
from ..config import settings
from . import n8n_proxy
from . import session as session_api
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/max", tags=["MAX"])
class MaxAuthRequest(BaseModel):
init_data: str
session_token: Optional[str] = None
phone: Optional[str] = None
class MaxAuthResponse(BaseModel):
success: bool
session_token: Optional[str] = None
unified_id: Optional[str] = None
contact_id: Optional[str] = None
phone: Optional[str] = None
has_drafts: Optional[bool] = None
need_contact: Optional[bool] = None
def _generate_session_token() -> str:
import uuid
return f"sess-{uuid.uuid4()}"
@router.post("/auth", response_model=MaxAuthResponse)
async def max_auth(request: MaxAuthRequest):
"""
Авторизация пользователя через MAX WebApp (Mini App).
"""
init_data = request.init_data or ""
phone = (request.phone or "").strip()
logger.info(
"[MAX] POST /api/v1/max/auth: init_data длина=%s, phone=%s, session_token=%s",
len(init_data),
bool(phone),
bool(request.session_token),
)
if not init_data:
logger.warning("[MAX] init_data пустой")
raise HTTPException(status_code=400, detail="init_data обязателен")
bot_configured = bool((getattr(settings, "max_bot_token", None) or "").strip())
webhook_configured = bool((getattr(settings, "n8n_max_auth_webhook", None) or "").strip())
logger.info("[MAX] Конфиг: MAX_BOT_TOKEN=%s, N8N_MAX_AUTH_WEBHOOK=%s", bot_configured, webhook_configured)
try:
max_user = extract_max_user(request.init_data)
except MaxAuthError as e:
logger.warning("[MAX] Ошибка валидации initData: %s", e)
raise HTTPException(status_code=400, detail=str(e))
max_user_id = max_user["max_user_id"]
logger.info("[MAX] MAX user валиден: id=%s, username=%s", max_user_id, max_user.get("username"))
session_token = request.session_token or _generate_session_token()
n8n_payload = {
"max_user_id": max_user_id,
"username": max_user.get("username"),
"first_name": max_user.get("first_name"),
"last_name": max_user.get("last_name"),
"session_token": session_token,
"form_id": "ticket_form",
"init_data": request.init_data,
}
if phone:
n8n_payload["phone"] = phone
logger.info("[MAX] Валидация OK → вызов n8n webhook (max_user_id=%s)", max_user_id)
try:
class _DummyRequest:
def __init__(self, payload: dict):
self._payload = payload
async def json(self):
return self._payload
n8n_response = await n8n_proxy.proxy_max_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
n8n_data = jsonable_encoder(n8n_response)
except HTTPException:
raise
except Exception as e:
logger.exception("[MAX] Ошибка вызова n8n MAX auth webhook: %s", e)
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
need_contact = n8n_data.get("need_contact") or (n8n_data.get("result") or {}).get("need_contact")
if need_contact:
logger.info("[MAX] n8n: need_contact — юзер не в базе, закрываем приложение")
return MaxAuthResponse(success=False, need_contact=True)
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
phone_res = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
has_drafts = n8n_data.get("has_drafts")
if not unified_id:
logger.error("[MAX] n8n не вернул unified_id. Ответ: %s", n8n_data)
raise HTTPException(status_code=500, detail="n8n не вернул unified_id для пользователя MAX")
session_request = session_api.SessionCreateRequest(
session_token=session_token,
unified_id=unified_id,
phone=phone_res or phone or "",
contact_id=contact_id or "",
ttl_hours=24,
)
try:
await session_api.create_session(session_request)
except HTTPException:
raise
except Exception as e:
logger.exception("[MAX] Ошибка создания сессии в Redis")
raise HTTPException(status_code=500, detail=f"Ошибка создания сессии: {str(e)}")
return MaxAuthResponse(
success=True,
session_token=session_token,
unified_id=unified_id,
contact_id=contact_id,
phone=phone_res or phone,
has_drafts=has_drafts,
)

View File

@@ -21,6 +21,7 @@ N8N_FILE_UPLOAD_WEBHOOK = settings.n8n_file_upload_webhook or None
N8N_CREATE_CONTACT_WEBHOOK = settings.n8n_create_contact_webhook N8N_CREATE_CONTACT_WEBHOOK = settings.n8n_create_contact_webhook
N8N_CREATE_CLAIM_WEBHOOK = settings.n8n_create_claim_webhook N8N_CREATE_CLAIM_WEBHOOK = settings.n8n_create_claim_webhook
N8N_TG_AUTH_WEBHOOK = settings.n8n_tg_auth_webhook or None N8N_TG_AUTH_WEBHOOK = settings.n8n_tg_auth_webhook or None
N8N_MAX_AUTH_WEBHOOK = getattr(settings, "n8n_max_auth_webhook", None) or None
@router.post("/policy/check") @router.post("/policy/check")
@@ -286,6 +287,54 @@ async def proxy_telegram_auth(request: Request):
raise HTTPException(status_code=500, detail=f"Ошибка авторизации Telegram: {str(e)}") raise HTTPException(status_code=500, detail=f"Ошибка авторизации Telegram: {str(e)}")
@router.post("/max/auth")
async def proxy_max_auth(request: Request):
"""
Проксирует авторизацию MAX WebApp в n8n webhook.
Используется /api/v1/max/auth: backend валидирует initData, затем вызывает этот роут.
"""
if not N8N_MAX_AUTH_WEBHOOK:
logger.error("[MAX] N8N_MAX_AUTH_WEBHOOK не задан в .env")
raise HTTPException(status_code=500, detail="N8N MAX auth webhook не настроен")
try:
body = await request.json()
logger.info(
"[MAX] Proxy → n8n: max_user_id=%s, session_token=%s",
body.get("max_user_id", "unknown"),
body.get("session_token", "unknown"),
)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
N8N_MAX_AUTH_WEBHOOK,
json=body,
headers={"Content-Type": "application/json"},
)
response_text = response.text or ""
logger.info("[MAX] n8n webhook ответ: status=%s, len=%s", response.status_code, len(response_text))
if response.status_code == 200:
try:
return response.json()
except Exception as e:
logger.error("[MAX] Парсинг JSON: %s. Response: %s", e, response_text[:500])
raise HTTPException(status_code=500, detail="Ошибка парсинга ответа n8n")
logger.error("[MAX] n8n вернул %s: %s", response.status_code, response_text[:500])
raise HTTPException(
status_code=response.status_code,
detail=f"N8N MAX auth error: {response_text}",
)
except httpx.TimeoutException:
logger.error("[MAX] Таймаут n8n MAX auth webhook")
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n (MAX auth)")
except Exception as e:
logger.exception("[MAX] Ошибка вызова n8n MAX auth: %s", e)
raise HTTPException(status_code=500, detail=f"Ошибка авторизации MAX: {str(e)}")
@router.post("/claim/create") @router.post("/claim/create")
async def proxy_create_claim(request: Request): async def proxy_create_claim(request: Request):
""" """

View File

@@ -192,6 +192,12 @@ class Settings(BaseSettings):
# ============================================ # ============================================
telegram_bot_token: str = "" # Токен бота для проверки initData WebApp telegram_bot_token: str = "" # Токен бота для проверки initData WebApp
# ============================================
# MAX (мессенджер) — Mini App auth
# ============================================
max_bot_token: str = "" # Токен бота MAX для проверки initData WebApp
n8n_max_auth_webhook: str = "" # Webhook n8n: max_user_id → unified_id, contact_id, has_drafts
# ============================================ # ============================================
# LOGGING # LOGGING
# ============================================ # ============================================

View File

@@ -13,7 +13,7 @@ from .services.rabbitmq_service import rabbitmq_service
from .services.policy_service import policy_service from .services.policy_service import policy_service
from .services.crm_mysql_service import crm_mysql_service from .services.crm_mysql_service import crm_mysql_service
from .services.s3_service import s3_service from .services.s3_service import s3_service
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth, max_auth, auth2
# Настройка логирования # Настройка логирования
logging.basicConfig( logging.basicConfig(
@@ -119,6 +119,8 @@ app.include_router(session.router) # 🔑 Session management через Redis
app.include_router(documents.router) # 📄 Documents upload and processing app.include_router(documents.router) # 📄 Documents upload and processing
app.include_router(banks.router) # 🏦 Banks API (NSPK banks list) app.include_router(banks.router) # 🏦 Banks API (NSPK banks list)
app.include_router(telegram_auth.router) # 🤖 Telegram Mini App auth app.include_router(telegram_auth.router) # 🤖 Telegram Mini App auth
app.include_router(max_auth.router) # 📱 MAX Mini App auth
app.include_router(auth2.router) # 🆕 Alt auth endpoint (tg/max/sms)
@app.get("/") @app.get("/")

View File

@@ -0,0 +1,111 @@
"""
MAX WebApp (Mini App) auth helper.
Валидация initData от MAX Bridge — тот же алгоритм, что и у Telegram:
secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN), data_check_string без hash, сравнение с hash.
"""
import hashlib
import hmac
import json
import logging
from typing import Dict, Any
from urllib.parse import parse_qsl
from ..config import settings
logger = logging.getLogger(__name__)
class MaxAuthError(Exception):
"""Ошибка проверки подлинности MAX initData."""
def _parse_init_data(init_data: str) -> Dict[str, Any]:
"""Разбирает строку initData (query string) в словарь."""
data: Dict[str, Any] = {}
for key, value in parse_qsl(init_data, keep_blank_values=True):
data[key] = value
return data
def verify_max_init_data(init_data: str) -> Dict[str, Any]:
"""
Проверяет подпись initData по правилам MAX (аналогично Telegram).
- secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
- data_check_string: пары key=value без hash, сортировка по key, разделитель \n
- hex(HMAC_SHA256(secret_key, data_check_string)) === hash из initData
"""
if not init_data:
logger.warning("[MAX] verify_max_init_data: init_data пустой")
raise MaxAuthError("init_data is empty")
bot_token = (getattr(settings, "max_bot_token", None) or "").strip()
if not bot_token:
logger.warning("[MAX] MAX_BOT_TOKEN не задан в .env")
raise MaxAuthError("MAX bot token is not configured")
parsed = _parse_init_data(init_data)
logger.info("[MAX] initData распарсен, ключи: %s", list(parsed.keys()))
received_hash = parsed.pop("hash", None)
if not received_hash:
logger.warning("[MAX] В initData отсутствует поле hash")
raise MaxAuthError("Missing hash in init_data")
data_check_items = []
for key in sorted(parsed.keys()):
value = parsed[key]
data_check_items.append(f"{key}={value}")
data_check_string = "\n".join(data_check_items)
secret_key = hmac.new(
key="WebAppData".encode("utf-8"),
msg=bot_token.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
calculated_hash = hmac.new(
key=secret_key,
msg=data_check_string.encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(calculated_hash, received_hash):
logger.warning("[MAX] Подпись initData не совпадает")
raise MaxAuthError("Invalid init_data hash")
return parsed
def extract_max_user(init_data: str) -> Dict[str, Any]:
"""
Валидирует initData и возвращает данные пользователя MAX.
В поле `user` — JSON с id, first_name, last_name, username, language_code, photo_url и т.д.
"""
parsed = verify_max_init_data(init_data)
user_raw = parsed.get("user")
if not user_raw:
logger.warning("[MAX] В initData отсутствует поле user")
raise MaxAuthError("No user field in init_data")
try:
user_obj = json.loads(user_raw)
except Exception as e:
raise MaxAuthError(f"Failed to parse user JSON: {e}") from e
if "id" not in user_obj:
raise MaxAuthError("MAX user.id is missing")
return {
"max_user_id": str(user_obj.get("id")),
"username": user_obj.get("username"),
"first_name": user_obj.get("first_name"),
"last_name": user_obj.get("last_name"),
"language_code": user_obj.get("language_code"),
"photo_url": user_obj.get("photo_url"),
"raw": user_obj,
}

View File

@@ -7,12 +7,12 @@ version: '3.8'
services: services:
ticket_form_frontend_prod: ticket_form_frontend_prod:
container_name: ticket_form_frontend_prod container_name: miniapp_front
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile.prod dockerfile: Dockerfile.prod
ports: ports:
- "5176:3000" - "4176:3000"
environment: environment:
- VITE_API_URL=https://aiform.clientright.ru - VITE_API_URL=https://aiform.clientright.ru
- NODE_ENV=production - NODE_ENV=production
@@ -28,7 +28,7 @@ services:
retries: 3 retries: 3
ticket_form_backend_prod: ticket_form_backend_prod:
container_name: ticket_form_backend_prod container_name: miniapp_back
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -45,7 +45,7 @@ services:
labels: labels:
- "environment=production" - "environment=production"
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8200/health"] test: ["CMD", "curl", "-f", "http://localhost:4200/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3

View File

@@ -5,6 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clientright — защита прав потребителей</title> <title>Clientright — защита прав потребителей</title>
<!-- MAX Bridge: нужен для window.WebApp и initData при заходе из MAX -->
<script src="https://st.max.ru/js/max-web-app.js"></script>
<!-- Telegram SDK загружается динамически только при заходе из Telegram --> <!-- Telegram SDK загружается динамически только при заходе из Telegram -->
</head> </head>
<body> <body>

View File

@@ -16,6 +16,7 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"imask": "^7.6.1", "imask": "^7.6.1",
"jspdf": "^2.5.2", "jspdf": "^2.5.2",
"lucide-react": "^0.575.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-dropzone": "^14.3.5", "react-dropzone": "^14.3.5",
@@ -3562,6 +3563,14 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "0.575.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7725,6 +7734,12 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"lucide-react": {
"version": "0.575.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
"requires": {}
},
"math-intrinsics": { "math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@@ -13,33 +13,33 @@
"start": "serve -s dist -l 3000" "start": "serve -s dist -l 3000"
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"antd": "^5.21.6",
"@ant-design/icons": "^5.5.1", "@ant-design/icons": "^5.5.1",
"axios": "^1.7.7",
"@tanstack/react-query": "^5.59.16", "@tanstack/react-query": "^5.59.16",
"zustand": "^5.0.1", "antd": "^5.21.6",
"axios": "^1.7.7",
"browser-image-compression": "^2.0.2",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"imask": "^7.6.1", "imask": "^7.6.1",
"react-dropzone": "^14.3.5",
"socket.io-client": "^4.8.1",
"serve": "^14.2.1",
"jspdf": "^2.5.2", "jspdf": "^2.5.2",
"browser-image-compression": "^2.0.2" "lucide-react": "^0.575.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"react-router-dom": "^6.26.2",
"serve": "^14.2.1",
"socket.io-client": "^4.8.1",
"zustand": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.11", "@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"eslint": "^9.13.0",
"@typescript-eslint/eslint-plugin": "^8.11.0", "@typescript-eslint/eslint-plugin": "^8.11.0",
"@typescript-eslint/parser": "^8.11.0", "@typescript-eslint/parser": "^8.11.0",
"@vitejs/plugin-react": "^4.3.3",
"eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.13" "eslint-plugin-react-refresh": "^0.4.13",
"typescript": "^5.6.3",
"vite": "^5.4.10"
} }
} }

View File

@@ -1,7 +1,12 @@
import ClaimForm from './pages/ClaimForm' import ClaimForm from './pages/ClaimForm'
import HelloAuth from './pages/HelloAuth'
import './App.css' import './App.css'
function App() { function App() {
const pathname = window.location.pathname || '';
if (pathname.startsWith('/hello')) {
return <HelloAuth />;
}
return ( return (
<div className="App"> <div className="App">
<ClaimForm /> <ClaimForm />

View File

@@ -110,6 +110,8 @@ export default function ClaimForm() {
const [tgDebug, setTgDebug] = useState<string>(''); const [tgDebug, setTgDebug] = useState<string>('');
/** Дефолт = веб. Скин TG подставляется только при заходе через Telegram Mini App. */ /** Дефолт = веб. Скин TG подставляется только при заходе через Telegram Mini App. */
const [isTelegramMiniApp, setIsTelegramMiniApp] = useState(false); const [isTelegramMiniApp, setIsTelegramMiniApp] = useState(false);
/** Заход через MAX Mini App. */
const [isMaxMiniApp, setIsMaxMiniApp] = useState(false);
useEffect(() => { useEffect(() => {
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился! // 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
@@ -182,6 +184,68 @@ export default function ClaimForm() {
if (!webApp?.initData) { if (!webApp?.initData) {
const tg = getTg(); const tg = getTg();
console.log('[TG] После ожидания', maxWaitMs, 'ms: Telegram=', !!tg, 'WebApp=', !!tg?.WebApp, 'initData=', !!tg?.WebApp?.initData, '→ пропускаем tg/auth'); console.log('[TG] После ожидания', maxWaitMs, 'ms: Telegram=', !!tg, 'WebApp=', !!tg?.WebApp, 'initData=', !!tg?.WebApp?.initData, '→ пропускаем tg/auth');
// Если Telegram не найден — пробуем MAX Mini App (window.WebApp от MAX Bridge)
let maxWebApp = (window as any).WebApp;
const maxWait = 4000;
for (let t = 0; t < maxWait; t += 200) {
await new Promise((r) => setTimeout(r, 200));
maxWebApp = (window as any).WebApp;
if (maxWebApp?.initData && maxWebApp.initData.length > 0) break;
}
if (maxWebApp?.initData && typeof maxWebApp.initData === 'string' && maxWebApp.initData.length > 0) {
const hasHash = maxWebApp.initData.includes('hash=');
console.log('[MAX] Обнаружен MAX WebApp, initData длина=', maxWebApp.initData.length, ', есть hash=', hasHash);
setIsMaxMiniApp(true);
try { maxWebApp.ready?.(); } catch (_) {}
const existingToken = localStorage.getItem('session_token');
if (existingToken) {
console.log('[MAX] session_token уже есть → max/auth не вызываем');
setTelegramAuthChecked(true);
return;
}
setTgDebug('MAX: POST /api/v1/max/auth...');
try {
const maxRes = await fetch('/api/v1/max/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ init_data: maxWebApp.initData }),
});
const maxData = await maxRes.json();
if (maxData.need_contact) {
setTgDebug('MAX: Нужен контакт — закрываем приложение');
try { maxWebApp.close?.(); } catch (_) {}
setTelegramAuthChecked(true);
return;
}
if (maxRes.ok && maxData.success) {
if (maxData.session_token) {
localStorage.setItem('session_token', maxData.session_token);
sessionIdRef.current = maxData.session_token;
}
setFormData((prev) => ({
...prev,
unified_id: maxData.unified_id,
phone: maxData.phone,
contact_id: maxData.contact_id,
session_id: maxData.session_token,
}));
setIsPhoneVerified(true);
if (maxData.has_drafts) {
setShowDraftSelection(true);
setHasDrafts(true);
setCurrentStep(0);
} else {
setCurrentStep(1);
}
} else {
console.error('[MAX] max/auth ответ', maxRes.status, maxData);
}
} catch (e) {
console.error('[MAX] Ошибка max/auth:', e);
}
setTelegramAuthChecked(true);
return;
}
setTelegramAuthChecked(true); setTelegramAuthChecked(true);
return; return;
} }

View File

@@ -0,0 +1,115 @@
.hello-page {
min-height: 100vh;
padding: 32px;
background: #f5f7fb;
}
.hello-hero {
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
background: #ffffff;
margin-bottom: 24px;
}
.hello-hero-header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.hello-hero-profile {
display: flex;
align-items: center;
gap: 18px;
}
.hello-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.9);
}
.hello-avatar.placeholder {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.2), rgba(37, 99, 235, 0.4));
}
.hello-hero-title {
font-size: 24px;
font-weight: 600;
color: #111827;
}
.hello-hero-subtitle {
font-size: 14px;
color: #6b7280;
margin-top: 2px;
}
.hello-hero-body {
padding-top: 8px;
min-height: 140px;
display: flex;
align-items: center;
justify-content: center;
}
.hello-hero-error {
color: #d4380d;
text-align: center;
}
.hello-grid {
margin-top: 32px;
}
.tile-card {
border-radius: 16px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
min-height: 160px;
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
transition: transform 0.2s ease, box-shadow 0.2s ease;
padding: 24px 16px;
background: #ffffff;
text-align: center;
}
.tile-card:hover {
transform: translateY(-6px);
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
}
.tile-icon {
width: 56px;
height: 56px;
border-radius: 16px;
background: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08);
margin-bottom: 12px;
}
.tile-title {
font-size: 14px;
font-weight: 600;
color: #111827;
text-align: center;
}
@media (max-width: 768px) {
.hello-page {
padding: 16px;
}
.tile-card {
min-height: 140px;
}
}

View File

@@ -0,0 +1,235 @@
import { useEffect, useState } from 'react';
import { Card, Button, Input, Space, Spin, message, Row, Col } from 'antd';
import {
User,
IdCard,
Trophy,
ShieldCheck,
CalendarDays,
FileText,
HelpCircle,
Building2,
} from 'lucide-react';
import './HelloAuth.css';
type Status = 'idle' | 'loading' | 'success' | 'error';
export default function HelloAuth() {
const [status, setStatus] = useState<Status>('idle');
const [greeting, setGreeting] = useState<string>('Привет!');
const [error, setError] = useState<string>('');
const [avatar, setAvatar] = useState<string>('');
const [phone, setPhone] = useState<string>('');
const [code, setCode] = useState<string>('');
const [codeSent, setCodeSent] = useState<boolean>(false);
useEffect(() => {
const isTelegramContext = () => {
const url = window.location.href;
const ref = document.referrer;
const ua = navigator.userAgent;
return (
url.includes('tgWebAppData') ||
url.includes('tgWebAppVersion') ||
ref.includes('telegram') ||
ua.includes('Telegram')
);
};
const tryAuth = async () => {
setStatus('loading');
try {
// Telegram Mini App
if (isTelegramContext()) {
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-web-app.js';
script.async = true;
script.onload = async () => {
const tg = (window as any).Telegram;
const webApp = tg?.WebApp;
const initData = webApp?.initData;
if (initData) {
const res = await fetch('/api/v1/auth2/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ platform: 'tg', init_data: initData }),
});
const data = await res.json();
if (res.ok && data.success) {
setGreeting(data.greeting || 'Привет!');
if (data.avatar_url) {
setAvatar(data.avatar_url);
}
setStatus('success');
return;
}
setError(data.detail || 'Ошибка авторизации Telegram');
setStatus('error');
return;
}
setStatus('idle');
};
document.head.appendChild(script);
return;
}
// MAX Mini App
const maxWebApp = (window as any).WebApp;
const initData = maxWebApp?.initData;
if (initData) {
const res = await fetch('/api/v1/auth2/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ platform: 'max', init_data: initData }),
});
const data = await res.json();
if (res.ok && data.success) {
setGreeting(data.greeting || 'Привет!');
if (data.avatar_url) {
setAvatar(data.avatar_url);
}
setStatus('success');
return;
}
setError(data.detail || 'Ошибка авторизации MAX');
setStatus('error');
return;
}
// Fallback: SMS
setStatus('idle');
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setStatus('error');
}
};
tryAuth();
}, []);
const sendCode = async () => {
try {
if (!phone || phone.length < 10) {
message.error('Введите номер телефона');
return;
}
const normalized = phone.startsWith('7') ? phone : `7${phone}`;
const res = await fetch('/api/v1/sms/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: normalized }),
});
const data = await res.json();
if (!res.ok) {
message.error(data.detail || 'Ошибка отправки кода');
return;
}
setCodeSent(true);
message.success('Код отправлен');
} catch (e) {
message.error('Ошибка соединения');
}
};
const verifyCode = async () => {
try {
const normalized = phone.startsWith('7') ? phone : `7${phone}`;
const res = await fetch('/api/v1/auth2/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ platform: 'sms', phone: normalized, code }),
});
const data = await res.json();
if (res.ok && data.success) {
setGreeting(data.greeting || 'Привет!');
if (data.avatar_url) {
setAvatar(data.avatar_url);
}
setStatus('success');
return;
}
message.error(data.detail || 'Неверный код');
} catch (e) {
message.error('Ошибка соединения');
}
};
const tiles = [
{ title: 'Профиль', icon: User, color: '#2563EB' },
{ title: 'Членство', icon: IdCard, color: '#10B981' },
{ title: 'Достижения', icon: Trophy, color: '#F59E0B' },
{ title: 'Общественный контроллер', icon: ShieldCheck, color: '#22C55E' },
{ title: 'Календарь мероприятий', icon: CalendarDays, color: '#4F46E5' },
{ title: 'Образцы документов', icon: FileText, color: '#1E40AF' },
{ title: 'FAQ', icon: HelpCircle, color: '#0EA5E9' },
{ title: 'Регистрация компании', icon: Building2, color: '#0F766E' },
];
return (
<div className="hello-page">
<Card className="hello-hero" bordered={false}>
<div className="hello-hero-header">
<div className="hello-hero-profile">
{avatar ? (
<img src={avatar} alt="avatar" className="hello-avatar" />
) : (
<div className="hello-avatar placeholder" />
)}
<div>
<div className="hello-hero-title">{greeting}</div>
<div className="hello-hero-subtitle">Добро пожаловать в кабинет</div>
</div>
</div>
</div>
<div className="hello-hero-body">
{status === 'loading' ? (
<Spin size="large" tip="Авторизация..." />
) : status === 'success' ? (
<p>Теперь ты в системе можно продолжать</p>
) : status === 'error' ? (
<p className="hello-hero-error">{error}</p>
) : (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Input
placeholder="Телефон (10 цифр)"
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 10))}
/>
<Button type="primary" onClick={sendCode} block>
Отправить код
</Button>
{codeSent && (
<>
<Input
placeholder="Код из SMS"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
<Button type="primary" onClick={verifyCode} block>
Проверить
</Button>
</>
)}
</Space>
)}
</div>
</Card>
<Row gutter={[16, 16]} className="hello-grid">
{tiles.map((tile) => {
const Icon = tile.icon;
return (
<Col key={tile.title} xs={12} sm={8} md={6}>
<Card className="tile-card" hoverable bordered={false}>
<div className="tile-icon" style={{ color: tile.color }}>
<Icon size={26} strokeWidth={1.8} />
</div>
<div className="tile-title">{tile.title}</div>
</Card>
</Col>
);
})}
</Row>
</div>
);
}