feat: add soft ui auth page
This commit is contained in:
@@ -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"]
|
||||
|
||||
|
||||
240
backend/app/api/auth2.py
Normal file
240
backend/app/api/auth2.py
Normal 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
146
backend/app/api/max_auth.py
Normal 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,
|
||||
)
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
# ============================================
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
111
backend/app/services/max_auth.py
Normal file
111
backend/app/services/max_auth.py
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Clientright — защита прав потребителей</title>
|
||||
<!-- MAX Bridge: нужен для window.WebApp и initData при заходе из MAX -->
|
||||
<script src="https://st.max.ru/js/max-web-app.js"></script>
|
||||
<!-- Telegram SDK загружается динамически только при заходе из Telegram -->
|
||||
</head>
|
||||
<body>
|
||||
|
||||
15
frontend/package-lock.json
generated
15
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <HelloAuth />;
|
||||
}
|
||||
return (
|
||||
<div className="App">
|
||||
<ClaimForm />
|
||||
|
||||
@@ -110,6 +110,8 @@ export default function ClaimForm() {
|
||||
const [tgDebug, setTgDebug] = useState<string>('');
|
||||
/** Дефолт = веб. Скин 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;
|
||||
}
|
||||
|
||||
115
frontend/src/pages/HelloAuth.css
Normal file
115
frontend/src/pages/HelloAuth.css
Normal 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;
|
||||
}
|
||||
}
|
||||
235
frontend/src/pages/HelloAuth.tsx
Normal file
235
frontend/src/pages/HelloAuth.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user