diff --git a/backend/Dockerfile b/backend/Dockerfile index 078f4a1..5a1b1e0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -14,8 +14,8 @@ RUN pip install --no-cache-dir -r requirements.txt 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"] diff --git a/backend/app/api/auth2.py b/backend/app/api/auth2.py new file mode 100644 index 0000000..ea6a03f --- /dev/null +++ b/backend/app/api/auth2.py @@ -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="Неподдерживаемая платформа") diff --git a/backend/app/api/max_auth.py b/backend/app/api/max_auth.py new file mode 100644 index 0000000..c27733e --- /dev/null +++ b/backend/app/api/max_auth.py @@ -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, + ) diff --git a/backend/app/api/n8n_proxy.py b/backend/app/api/n8n_proxy.py index ce342c6..a127ec6 100644 --- a/backend/app/api/n8n_proxy.py +++ b/backend/app/api/n8n_proxy.py @@ -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_CLAIM_WEBHOOK = settings.n8n_create_claim_webhook 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") @@ -286,6 +287,54 @@ async def proxy_telegram_auth(request: Request): 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") async def proxy_create_claim(request: Request): """ diff --git a/backend/app/config.py b/backend/app/config.py index e56694e..49e8282 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -191,7 +191,13 @@ class Settings(BaseSettings): # TELEGRAM BOT # ============================================ 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 # ============================================ diff --git a/backend/app/main.py b/backend/app/main.py index 4630703..940e341 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ from .services.rabbitmq_service import rabbitmq_service from .services.policy_service import policy_service from .services.crm_mysql_service import crm_mysql_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( @@ -119,6 +119,8 @@ app.include_router(session.router) # 🔑 Session management через Redis app.include_router(documents.router) # 📄 Documents upload and processing app.include_router(banks.router) # 🏦 Banks API (NSPK banks list) 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("/") diff --git a/backend/app/services/max_auth.py b/backend/app/services/max_auth.py new file mode 100644 index 0000000..454d455 --- /dev/null +++ b/backend/app/services/max_auth.py @@ -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, + } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 53b0bae..9109d80 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -7,12 +7,12 @@ version: '3.8' services: ticket_form_frontend_prod: - container_name: ticket_form_frontend_prod + container_name: miniapp_front build: context: ./frontend dockerfile: Dockerfile.prod ports: - - "5176:3000" + - "4176:3000" environment: - VITE_API_URL=https://aiform.clientright.ru - NODE_ENV=production @@ -28,7 +28,7 @@ services: retries: 3 ticket_form_backend_prod: - container_name: ticket_form_backend_prod + container_name: miniapp_back build: context: ./backend dockerfile: Dockerfile @@ -45,7 +45,7 @@ services: labels: - "environment=production" healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8200/health"] + test: ["CMD", "curl", "-f", "http://localhost:4200/health"] interval: 30s timeout: 10s retries: 3 diff --git a/frontend/index.html b/frontend/index.html index 0002d09..fe5dc8f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,8 @@ Clientright — защита прав потребителей + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c76312e..5cd4905 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "dayjs": "^1.11.13", "imask": "^7.6.1", "jspdf": "^2.5.2", + "lucide-react": "^0.575.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.3.5", @@ -3562,6 +3563,14 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7725,6 +7734,12 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 35e2148..fc40fce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,33 +13,33 @@ "start": "serve -s dist -l 3000" }, "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", - "axios": "^1.7.7", "@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", "imask": "^7.6.1", - "react-dropzone": "^14.3.5", - "socket.io-client": "^4.8.1", - "serve": "^14.2.1", "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": { "@types/react": "^18.3.11", "@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/parser": "^8.11.0", + "@vitejs/plugin-react": "^4.3.3", + "eslint": "^9.13.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" } } - diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 69560f1..d619fd1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,12 @@ import ClaimForm from './pages/ClaimForm' +import HelloAuth from './pages/HelloAuth' import './App.css' function App() { + const pathname = window.location.pathname || ''; + if (pathname.startsWith('/hello')) { + return ; + } return (
diff --git a/frontend/src/pages/ClaimForm.tsx b/frontend/src/pages/ClaimForm.tsx index 3aff96c..b406e79 100644 --- a/frontend/src/pages/ClaimForm.tsx +++ b/frontend/src/pages/ClaimForm.tsx @@ -110,6 +110,8 @@ export default function ClaimForm() { const [tgDebug, setTgDebug] = useState(''); /** Дефолт = веб. Скин TG подставляется только при заходе через Telegram Mini App. */ const [isTelegramMiniApp, setIsTelegramMiniApp] = useState(false); + /** Заход через MAX Mini App. */ + const [isMaxMiniApp, setIsMaxMiniApp] = useState(false); useEffect(() => { // 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился! @@ -182,6 +184,68 @@ export default function ClaimForm() { if (!webApp?.initData) { const tg = getTg(); 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); return; } diff --git a/frontend/src/pages/HelloAuth.css b/frontend/src/pages/HelloAuth.css new file mode 100644 index 0000000..578fe63 --- /dev/null +++ b/frontend/src/pages/HelloAuth.css @@ -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; + } +} diff --git a/frontend/src/pages/HelloAuth.tsx b/frontend/src/pages/HelloAuth.tsx new file mode 100644 index 0000000..643bd1d --- /dev/null +++ b/frontend/src/pages/HelloAuth.tsx @@ -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('idle'); + const [greeting, setGreeting] = useState('Привет!'); + const [error, setError] = useState(''); + const [avatar, setAvatar] = useState(''); + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [codeSent, setCodeSent] = useState(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 ( +
+ +
+
+ {avatar ? ( + avatar + ) : ( +
+ )} +
+
{greeting}
+
Добро пожаловать в кабинет
+
+
+
+
+ {status === 'loading' ? ( + + ) : status === 'success' ? ( +

Теперь ты в системе — можно продолжать

+ ) : status === 'error' ? ( +

{error}

+ ) : ( + + setPhone(e.target.value.replace(/\D/g, '').slice(0, 10))} + /> + + {codeSent && ( + <> + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + /> + + + )} + + )} +
+ + + + {tiles.map((tile) => { + const Icon = tile.icon; + return ( + + +
+ +
+
{tile.title}
+
+ + ); + })} +
+
+ ); +}