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 . .
|
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_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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -191,7 +191,13 @@ class Settings(BaseSettings):
|
|||||||
# TELEGRAM BOT
|
# TELEGRAM BOT
|
||||||
# ============================================
|
# ============================================
|
||||||
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
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -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("/")
|
||||||
|
|||||||
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:
|
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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
15
frontend/package-lock.json
generated
15
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
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