Simplify Evotor connect to manual token entry only

Resolves #2 — removes semi-automatic OAuth flow (Переподключить button,
/evotor/connect and /evotor/link routes) and makes manual token entry
the sole connect option. Adds step-by-step instructions with a direct
link to the app on Evotor marketplace (opens in new tab).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-03-10 13:01:12 +03:00
parent 577c5de200
commit 00b74b8aa9
2 changed files with 28 additions and 103 deletions

View File

@@ -1,7 +1,7 @@
import logging import logging
import httpx import httpx
from datetime import datetime, timedelta from datetime import datetime
from fastapi import APIRouter, Request, Depends, HTTPException from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import RedirectResponse, JSONResponse from fastapi.responses import RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -21,9 +21,6 @@ templates = Jinja2Templates(directory="web/templates")
EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}" EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}"
EVOTOR_STORES_URL = "https://api.evotor.ru/stores" EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
# Pending connections older than this are ignored during linking
PENDING_LINK_WINDOW_SECONDS = 300
@router.get("") @router.get("")
def evotor_page( def evotor_page(
@@ -36,27 +33,16 @@ def evotor_page(
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
error = request.query_params.get("error") error = request.query_params.get("error")
app_url = EVOTOR_APP_URL.format(app_id=settings.EVOTOR_APP_ID) if settings.EVOTOR_APP_ID else None
return templates.TemplateResponse("evotor.html", { return templates.TemplateResponse("evotor.html", {
"request": request, "request": request,
"user": user, "user": user,
"connection": connection, "connection": connection,
"error": error, "error": error,
"app_url": app_url,
}) })
@router.get("/connect")
def evotor_connect(request: Request, user: User | None = Depends(get_current_user)):
if not user:
return RedirectResponse("/login", 303)
# Record when this user initiated a connect so we can link the incoming webhook
request.session["evotor_connect_user_id"] = user.id
request.session["evotor_connect_at"] = datetime.utcnow().isoformat()
url = EVOTOR_APP_URL.format(app_id=settings.EVOTOR_APP_ID)
return RedirectResponse(url, 302)
class EvotorTokenPayload(BaseModel): class EvotorTokenPayload(BaseModel):
userId: str userId: str
token: str token: str
@@ -130,62 +116,6 @@ async def evotor_callback(
return JSONResponse({"status": "ok"}) return JSONResponse({"status": "ok"})
@router.get("/link")
def evotor_link(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
"""
Called when the user returns to our app after authorizing on Evotor.
Links the most recently received unlinked token to this user.
"""
if not user:
return RedirectResponse("/login", 303)
connect_user_id = request.session.pop("evotor_connect_user_id", None)
connect_at_str = request.session.pop("evotor_connect_at", None)
if not connect_user_id or connect_user_id != user.id or not connect_at_str:
return RedirectResponse("/evotor?error=session_expired", 303)
try:
connect_at = datetime.fromisoformat(connect_at_str)
except ValueError:
return RedirectResponse("/evotor?error=session_expired", 303)
cutoff = connect_at - timedelta(seconds=10) # allow slight clock drift
now = datetime.utcnow()
if (now - connect_at).total_seconds() > PENDING_LINK_WINDOW_SECONDS:
return RedirectResponse("/evotor?error=link_timeout", 303)
# Find an unlinked connection received after the user clicked "Connect"
pending = (
db.query(EvotorConnection)
.filter(
EvotorConnection.user_id.is_(None),
EvotorConnection.connected_at >= cutoff,
)
.order_by(EvotorConnection.connected_at.desc())
.first()
)
if not pending:
return RedirectResponse("/evotor?error=token_not_received", 303)
# Detach any existing connection for this user
existing = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if existing:
db.delete(existing)
db.flush()
pending.user_id = user.id
db.commit()
return RedirectResponse("/connections", 303)
@router.post("/token") @router.post("/token")
async def evotor_token_manual( async def evotor_token_manual(
request: Request, request: Request,

View File

@@ -7,13 +7,7 @@
{% if error %} {% if error %}
<div class="alert alert-danger mt-4"> <div class="alert alert-danger mt-4">
{% if error == "token_not_received" %} {% if error == "invalid_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Токен от Эвотор ещё не получен. Убедитесь, что авторизация прошла успешно, и попробуйте снова.
{% elif error == "link_timeout" %}
<i class="bi bi-exclamation-triangle me-2"></i>Время ожидания истекло. Попробуйте подключить аккаунт заново.
{% elif error == "session_expired" %}
<i class="bi bi-exclamation-triangle me-2"></i>Сессия устарела. Попробуйте подключить аккаунт заново.
{% elif error == "invalid_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Проверьте правильность и попробуйте снова. <i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Проверьте правильность и попробуйте снова.
{% elif error == "empty_token" %} {% elif error == "empty_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Введите токен. <i class="bi bi-exclamation-triangle me-2"></i>Введите токен.
@@ -52,14 +46,8 @@
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span> <span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
</li> </li>
</ul> </ul>
<div class="card-body d-grid gap-2">
<a href="/evotor/connect" class="btn btn-primary">Переподключить</a>
<form method="post" action="/evotor/disconnect">
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button>
</form>
</div>
<div class="card-footer"> <div class="card-footer">
<p class="text-muted small mb-2">Обновить токен вручную (Личный кабинет Эвотор → <strong>Приложения → ЭвоСинк → Настройки</strong>):</p> <p class="text-muted small mb-2">Обновить токен (Личный кабинет Эвотор → <strong>Приложения → ЭвоСинк → Настройки</strong>):</p>
<form method="post" action="/evotor/token"> <form method="post" action="/evotor/token">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен" required> <input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен" required>
@@ -67,30 +55,37 @@
</div> </div>
</form> </form>
</div> </div>
<div class="card-body d-grid">
<form method="post" action="/evotor/disconnect">
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button>
</form>
</div>
{% else %} {% else %}
{# ── NOT CONNECTED STATE ── #} {# ── NOT CONNECTED STATE ── #}
<div class="card-body"> <div class="card-body">
<p class="text-muted mb-3"> <p class="text-muted mb-3">
Подключите ваш аккаунт Эвотор, чтобы система могла автоматически синхронизировать Для подключения вам нужно установить приложение <strong>ЭвоСинк</strong> в личном кабинете Эвотор
каталог товаров из вашей кассы в ВКонтакте. и скопировать токен доступа из его настроек.
</p> </p>
<ul class="text-muted small mb-4">
<li>Вы будете перенаправлены на сайт Эвотор для авторизации</li>
<li>После подтверждения доступа синхронизация будет настроена автоматически</li>
<li>Вы можете отключить доступ в любой момент</li>
</ul>
<div class="d-grid gap-2">
<a href="/evotor/connect" class="btn btn-primary btn-lg">Подключить Эвотор</a>
<a href="/evotor/link" class="btn btn-outline-secondary">Уже авторизовался — подтвердить подключение</a>
</div>
<hr class="my-4"> <ol class="text-muted small mb-4">
<p class="text-muted small mb-2">Если приложение уже установлено, введите токен вручную. Его можно найти в личном кабинете Эвотор: <strong>Приложения → ЭвоСинк → Настройки</strong>.</p> {% if app_url %}
<li class="mb-1">Откройте <a href="{{ app_url }}" target="_blank" rel="noopener">приложение ЭвоСинк в магазине Эвотор <i class="bi bi-box-arrow-up-right small"></i></a> и установите его.</li>
{% else %}
<li class="mb-1">Найдите приложение <strong>ЭвоСинк</strong> в магазине Эвотор и установите его.</li>
{% endif %}
<li class="mb-1">Перейдите в раздел <strong>Приложения → ЭвоСинк → Настройки</strong>.</li>
<li class="mb-1">Скопируйте токен доступа и вставьте его в поле ниже.</li>
</ol>
<form method="post" action="/evotor/token"> <form method="post" action="/evotor/token">
<div class="input-group"> <div class="mb-3">
<input type="text" name="token" class="form-control font-monospace" placeholder="Введите токен Эвотор" required> <label class="form-label small text-muted">Токен доступа</label>
<button type="submit" class="btn btn-outline-primary">Сохранить</button> <input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте токен Эвотор" required autofocus>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Подключить</button>
</div> </div>
</form> </form>
</div> </div>