Implement internal Hub baseline and auth flow

This commit is contained in:
Andrey committed 2026-06-17 20:47:58 +03:00
1 parent 0abc421766
commit 7d4c4ba6ef
17 files changed
+4685 -83

No files matched your search

+1 -1
View File
@@ -32,5 +32,5 @@ Default local URLs:
Create or refresh the local OWNER account:
```powershell
docker compose run --rm backend python manage.py bootstrap_owner --email owner@edevs.tech --password local-owner-password --name "Edevs Owner"
docker compose run --rm backend python manage.py bootstrap_owner --email owner@edevs.tech --password local-owner-password --name "Иван Петров"
```
+1
View File
@@ -6,6 +6,7 @@ urlpatterns = [
path("admin/", admin.site.urls),
path("api/v1/schema/", get_schema_view(title="Edevs Hub API", version="0.1.0"), name="openapi-schema"),
path("api/v1/auth/", include("hub_platform.identity.auth_urls")),
path("api/v1/company/", include("hub_platform.identity.company_urls")),
path("api/v1/employees/", include("hub_platform.identity.employee_urls")),
path("api/v1/health/", include("hub_platform.health.urls")),
]
@@ -7,4 +7,7 @@ urlpatterns = [
path("login/", auth_views.login_view, name="auth-login"),
path("logout/", auth_views.logout_view, name="auth-logout"),
path("change-temporary-password/", auth_views.change_temporary_password_view, name="auth-change-temp-password"),
path("totp/setup/", auth_views.totp_setup_view, name="auth-totp-setup"),
path("totp/confirm/", auth_views.totp_confirm_view, name="auth-totp-confirm"),
path("totp/verify/", auth_views.totp_verify_view, name="auth-totp-verify"),
]
@@ -1,13 +1,25 @@
import base64
import hashlib
import hmac
import json
import os
import struct
import time
from urllib.parse import quote
from django.contrib.auth import authenticate, login, logout
from django.http import HttpRequest, JsonResponse
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.http import require_GET, require_POST
from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.models import AuditResult, HumanUser
TOTP_SESSION_KEY = "identity_pending_totp_user_id"
TOTP_ISSUER = "Edevs Hub"
TOTP_PERIOD_SECONDS = 30
def _json_body(request: HttpRequest) -> dict[str, object]:
if not request.body:
@@ -18,9 +30,11 @@ def _json_body(request: HttpRequest) -> dict[str, object]:
def _user_payload(user: HumanUser) -> dict[str, object]:
profile = user.employee_profile
return {
"id": user.id,
"email": user.email,
"fullName": user.full_name,
"role": profile.role,
"organizationName": profile.organization.name,
"organization": profile.organization.slug,
"department": profile.department.code if profile.department else None,
"mustChangePassword": profile.must_change_password,
@@ -29,6 +43,54 @@ def _user_payload(user: HumanUser) -> dict[str, object]:
}
def _challenge_payload(user: HumanUser) -> dict[str, object]:
profile = user.employee_profile
return {
"email": user.email,
"fullName": user.full_name,
"role": profile.role,
}
def _generate_totp_secret() -> str:
return base64.b32encode(os.urandom(20)).decode("ascii").rstrip("=")
def _decode_totp_secret(secret: str) -> bytes:
normalized = secret.strip().replace(" ", "").upper()
padding = "=" * ((8 - len(normalized) % 8) % 8)
return base64.b32decode(normalized + padding)
def _totp_code(secret: str, for_time: int | None = None) -> str:
timestamp = int(time.time() if for_time is None else for_time)
counter = timestamp // TOTP_PERIOD_SECONDS
digest = hmac.new(_decode_totp_secret(secret), struct.pack(">Q", counter), hashlib.sha1).digest()
offset = digest[-1] & 0x0F
code = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF
return f"{code % 1_000_000:06d}"
def _verify_totp(secret: str, code: str) -> bool:
normalized = "".join(character for character in code if character.isdigit())
if len(normalized) != 6:
return False
now = int(time.time())
return any(
hmac.compare_digest(_totp_code(secret, now + (offset * TOTP_PERIOD_SECONDS)), normalized)
for offset in (-1, 0, 1)
)
def _ensure_totp_secret(user: HumanUser) -> str:
profile = user.employee_profile
if not profile.totp_secret:
profile.totp_secret = _generate_totp_secret()
profile.save(update_fields=["totp_secret"])
return profile.totp_secret
@ensure_csrf_cookie
@require_GET
def session_view(request: HttpRequest) -> JsonResponse:
if not request.user.is_authenticated:
@@ -42,6 +104,7 @@ def login_view(request: HttpRequest) -> JsonResponse:
body = _json_body(request)
email = str(body.get("email", ""))
password = str(body.get("password", ""))
request.session.pop(TOTP_SESSION_KEY, None)
user = authenticate(request, username=email, password=password)
if user is None:
record_audit_event(action="identity.login_failed", result=AuditResult.DENIED, request=request)
@@ -50,11 +113,29 @@ def login_view(request: HttpRequest) -> JsonResponse:
record_audit_event(action="identity.login_blocked", actor=user, result=AuditResult.DENIED, request=request)
return JsonResponse({"detail": "Account is blocked"}, status=403)
profile = user.employee_profile
if profile.totp_enabled:
request.session[TOTP_SESSION_KEY] = user.id
record_audit_event(
action="identity.login_totp_required",
actor=user,
organization=profile.organization,
request=request,
)
return JsonResponse(
{
"authenticated": False,
"totpRequired": True,
"totpEnabled": True,
"challenge": _challenge_payload(user),
}
)
login(request, user)
record_audit_event(
action="identity.login_succeeded",
actor=user,
organization=user.employee_profile.organization,
organization=profile.organization,
request=request,
)
return JsonResponse({"authenticated": True, "user": _user_payload(user)})
@@ -79,14 +160,14 @@ def change_temporary_password_view(request: HttpRequest) -> JsonResponse:
body = _json_body(request)
current_password = str(body.get("currentPassword", ""))
new_password = str(body.get("newPassword", ""))
if not request.user.check_password(current_password):
profile = request.user.employee_profile
if not profile.must_change_password and not request.user.check_password(current_password):
return JsonResponse({"detail": "Current password is invalid"}, status=400)
if len(new_password) < 12:
if len(new_password) < 10:
return JsonResponse({"detail": "Password is too short"}, status=400)
request.user.set_password(new_password)
request.user.save(update_fields=["password"])
profile = request.user.employee_profile
profile.must_change_password = False
profile.save(update_fields=["must_change_password"])
login(request, request.user)
@@ -97,3 +178,105 @@ def change_temporary_password_view(request: HttpRequest) -> JsonResponse:
request=request,
)
return JsonResponse({"authenticated": True, "user": _user_payload(request.user)})
@csrf_protect
@require_GET
def totp_setup_view(request: HttpRequest) -> JsonResponse:
if not request.user.is_authenticated:
return JsonResponse({"detail": "Authentication required"}, status=401)
profile = request.user.employee_profile
if not profile.totp_required:
return JsonResponse({"detail": "TOTP is not required"}, status=400)
if profile.totp_enabled:
return JsonResponse({"detail": "TOTP is already enabled"}, status=400)
secret = _ensure_totp_secret(request.user)
account_name = request.user.email
otpauth_url = (
f"otpauth://totp/{quote(TOTP_ISSUER)}:{quote(account_name)}"
f"?secret={secret}&issuer={quote(TOTP_ISSUER)}&digits=6&period={TOTP_PERIOD_SECONDS}"
)
return JsonResponse(
{
"secret": secret,
"otpauthUrl": otpauth_url,
"accountName": account_name,
"issuer": TOTP_ISSUER,
"period": TOTP_PERIOD_SECONDS,
}
)
@csrf_protect
@require_POST
def totp_confirm_view(request: HttpRequest) -> JsonResponse:
if not request.user.is_authenticated:
return JsonResponse({"detail": "Authentication required"}, status=401)
profile = request.user.employee_profile
if not profile.totp_required:
return JsonResponse({"detail": "TOTP is not required"}, status=400)
body = _json_body(request)
secret = _ensure_totp_secret(request.user)
if not _verify_totp(secret, str(body.get("code", ""))):
record_audit_event(
action="identity.totp_setup_failed",
actor=request.user,
organization=profile.organization,
result=AuditResult.DENIED,
request=request,
)
return JsonResponse({"detail": "Invalid TOTP code"}, status=400)
profile.totp_enabled = True
profile.save(update_fields=["totp_enabled"])
record_audit_event(
action="identity.totp_enabled",
actor=request.user,
organization=profile.organization,
request=request,
)
return JsonResponse({"authenticated": True, "user": _user_payload(request.user)})
@csrf_protect
@require_POST
def totp_verify_view(request: HttpRequest) -> JsonResponse:
pending_user_id = request.session.get(TOTP_SESSION_KEY)
if not pending_user_id:
return JsonResponse({"detail": "TOTP challenge is not active"}, status=401)
try:
user = HumanUser.objects.select_related("employee_profile", "employee_profile__organization").get(
id=pending_user_id
)
except HumanUser.DoesNotExist:
request.session.pop(TOTP_SESSION_KEY, None)
return JsonResponse({"detail": "TOTP challenge is not active"}, status=401)
profile = user.employee_profile
body = _json_body(request)
if (
not profile.totp_enabled
or not profile.totp_secret
or not _verify_totp(profile.totp_secret, str(body.get("code", "")))
):
record_audit_event(
action="identity.totp_verify_failed",
actor=user,
organization=profile.organization,
result=AuditResult.DENIED,
request=request,
)
return JsonResponse({"detail": "Invalid TOTP code"}, status=400)
request.session.pop(TOTP_SESSION_KEY, None)
login(request, user)
record_audit_event(
action="identity.login_succeeded",
actor=user,
organization=profile.organization,
request=request,
)
return JsonResponse({"authenticated": True, "user": _user_payload(user)})
@@ -0,0 +1,10 @@
from django.urls import path
from hub_platform.identity import company_views
urlpatterns = [
path("departments/", company_views.department_list_view, name="department-list"),
path("products/", company_views.product_list_view, name="product-list"),
path("products/create/", company_views.create_product_view, name="product-create"),
path("products/<int:product_id>/deactivate/", company_views.deactivate_product_view, name="product-deactivate"),
]
@@ -0,0 +1,132 @@
import json
from django.db import transaction
from django.http import HttpRequest, JsonResponse
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.http import require_GET, require_POST
from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.employee_views import owner_required
from hub_platform.identity.models import (
AuditResult,
Department,
EmployeeRole,
Product,
ProductStatus,
)
def _json_body(request: HttpRequest) -> dict[str, object]:
if not request.body:
return {}
return json.loads(request.body.decode("utf-8"))
def _require_authenticated_profile(request: HttpRequest):
if not request.user.is_authenticated:
return None, JsonResponse({"detail": "Authentication required"}, status=401)
return request.user.employee_profile, None
def _department_payload(department: Department) -> dict[str, object]:
employees = list(department.employees.select_related("user").all())
operators = [employee for employee in employees if employee.role == EmployeeRole.OPERATOR]
products = list(department.organization.products.order_by("name"))
return {
"id": department.id,
"code": department.code,
"name": department.name,
"status": department.status,
"memberCount": len(employees),
"operatorCount": len(operators),
"activeOperatorCount": len(
[employee for employee in operators if employee.user.is_active and not employee.is_blocked]
),
"products": [{"code": product.code, "name": product.name} for product in products],
}
def _product_payload(product: Product) -> dict[str, object]:
return {
"id": product.id,
"code": product.code,
"name": product.name,
"status": product.status,
"siteUrl": product.site_url,
"createdAt": product.created_at.isoformat(),
}
@require_GET
def department_list_view(request: HttpRequest) -> JsonResponse:
profile, error = _require_authenticated_profile(request)
if error is not None:
return error
departments = Department.objects.filter(organization=profile.organization).order_by("name")
if profile.role == EmployeeRole.OPERATOR:
departments = departments.filter(id=profile.department_id)
return JsonResponse({"items": [_department_payload(department) for department in departments]})
@require_GET
def product_list_view(request: HttpRequest) -> JsonResponse:
profile, error = _require_authenticated_profile(request)
if error is not None:
return error
products = Product.objects.filter(organization=profile.organization).order_by("name")
return JsonResponse({"items": [_product_payload(product) for product in products]})
@csrf_protect
@require_POST
@owner_required
@transaction.atomic
def create_product_view(request: HttpRequest) -> JsonResponse:
owner_profile = request.user.employee_profile
body = _json_body(request)
code = str(body.get("code", "")).strip().lower()
name = str(body.get("name", "")).strip()
site_url = str(body.get("siteUrl", "")).strip()
if not code:
return JsonResponse({"detail": "Product code is required"}, status=400)
if not name:
return JsonResponse({"detail": "Product name is required"}, status=400)
product = Product.objects.create(
organization=owner_profile.organization,
code=code,
name=name,
site_url=site_url,
)
record_audit_event(
action="identity.product_created",
actor=request.user,
organization=owner_profile.organization,
object_type="Product",
object_id=str(product.id),
request=request,
)
return JsonResponse({"product": _product_payload(product)}, status=201)
@csrf_protect
@require_POST
@owner_required
@transaction.atomic
def deactivate_product_view(request: HttpRequest, product_id: int) -> JsonResponse:
owner_profile = request.user.employee_profile
try:
product = Product.objects.get(id=product_id, organization=owner_profile.organization)
except Product.DoesNotExist:
return JsonResponse({"detail": "Product not found"}, status=404)
product.status = ProductStatus.DISABLED
product.save(update_fields=["status"])
record_audit_event(
action="identity.product_deactivated",
actor=request.user,
organization=owner_profile.organization,
object_type="Product",
object_id=str(product.id),
result=AuditResult.SUCCESS,
request=request,
)
return JsonResponse({"product": _product_payload(product)})
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("identity", "0002_alter_humanuser_managers_and_more"),
]
operations = [
migrations.AddField(
model_name="employeeprofile",
name="totp_secret",
field=models.CharField(blank=True, max_length=64),
),
]
@@ -131,6 +131,7 @@ class EmployeeProfile(models.Model):
must_change_password = models.BooleanField(default=False)
totp_required = models.BooleanField(default=False)
totp_enabled = models.BooleanField(default=False)
totp_secret = models.CharField(max_length=64, blank=True)
blocked_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
+111
View File
@@ -3,6 +3,7 @@ import json
from django.test import Client, TestCase
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.auth_views import _totp_code
from hub_platform.identity.models import (
AuditEvent,
Department,
@@ -95,8 +96,15 @@ class AuthEndpointTests(TestCase):
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertTrue(payload["authenticated"])
self.assertEqual(payload["user"]["organizationName"], "Edevs")
self.assertEqual(payload["user"]["role"], EmployeeRole.OWNER)
def test_session_sets_csrf_cookie_for_spa(self) -> None:
response = self.client.get("/api/v1/auth/session/")
self.assertEqual(response.status_code, 200)
self.assertIn("csrftoken", response.cookies)
def test_login_rejects_invalid_password(self) -> None:
response = self.client.post(
"/api/v1/auth/login/",
@@ -129,6 +137,52 @@ class AuthEndpointTests(TestCase):
profile.refresh_from_db()
self.assertFalse(profile.must_change_password)
def test_totp_setup_and_confirm_enables_profile_totp(self) -> None:
self.client.login(username="owner@edevs.tech", password="temporary-password")
setup_response = self.client.get("/api/v1/auth/totp/setup/")
self.assertEqual(setup_response.status_code, 200)
secret = setup_response.json()["secret"]
self.assertTrue(secret)
self.assertIn("otpauth://totp/", setup_response.json()["otpauthUrl"])
confirm_response = self.client.post(
"/api/v1/auth/totp/confirm/",
data=json.dumps({"code": _totp_code(secret)}),
content_type="application/json",
)
self.assertEqual(confirm_response.status_code, 200)
owner = HumanUser.objects.get(email="owner@edevs.tech")
self.assertTrue(owner.employee_profile.totp_enabled)
def test_enabled_totp_requires_second_factor_before_session(self) -> None:
owner = HumanUser.objects.get(email="owner@edevs.tech")
profile = owner.employee_profile
profile.totp_secret = "JBSWY3DPEHPK3PXP"
profile.totp_enabled = True
profile.save(update_fields=["totp_secret", "totp_enabled"])
login_response = self.client.post(
"/api/v1/auth/login/",
data=json.dumps({"email": "owner@edevs.tech", "password": "temporary-password"}),
content_type="application/json",
)
self.assertEqual(login_response.status_code, 200)
self.assertFalse(login_response.json()["authenticated"])
self.assertTrue(login_response.json()["totpRequired"])
verify_response = self.client.post(
"/api/v1/auth/totp/verify/",
data=json.dumps({"code": _totp_code(profile.totp_secret)}),
content_type="application/json",
)
self.assertEqual(verify_response.status_code, 200)
self.assertTrue(verify_response.json()["authenticated"])
class DjangoAdminTests(TestCase):
def test_bootstrapped_owner_can_access_django_admin(self) -> None:
@@ -210,3 +264,60 @@ class EmployeeEndpointTests(TestCase):
self.assertFalse(operator.is_active)
self.assertTrue(operator.employee_profile.is_blocked)
self.assertTrue(AuditEvent.objects.filter(action="identity.operator_blocked").exists())
class CompanyEndpointTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.sales = Department.objects.get(code="sales")
self.client = Client()
self.client.login(username="owner@edevs.tech", password="temporary-password")
def test_owner_reads_departments_and_products(self) -> None:
departments_response = self.client.get("/api/v1/company/departments/")
products_response = self.client.get("/api/v1/company/products/")
self.assertEqual(departments_response.status_code, 200)
self.assertEqual(products_response.status_code, 200)
self.assertEqual(departments_response.json()["items"][0]["code"], "sales")
self.assertEqual(
set(product["code"] for product in products_response.json()["items"]),
{"firepage", "foxray"},
)
def test_owner_creates_and_deactivates_product(self) -> None:
create_response = self.client.post(
"/api/v1/company/products/create/",
data=json.dumps({"code": "academy", "name": "Academy"}),
content_type="application/json",
)
self.assertEqual(create_response.status_code, 201)
product_id = create_response.json()["product"]["id"]
deactivate_response = self.client.post(f"/api/v1/company/products/{product_id}/deactivate/")
self.assertEqual(deactivate_response.status_code, 200)
self.assertEqual(deactivate_response.json()["product"]["status"], "DISABLED")
self.assertTrue(AuditEvent.objects.filter(action="identity.product_created").exists())
self.assertTrue(AuditEvent.objects.filter(action="identity.product_deactivated").exists())
def test_operator_cannot_create_product(self) -> None:
operator = HumanUser.objects.create_user(email="operator@edevs.tech", password="operator-password")
EmployeeProfile.objects.create(
user=operator,
organization=self.organization,
role=EmployeeRole.OPERATOR,
department=self.sales,
)
self.client.logout()
self.client.login(username="operator@edevs.tech", password="operator-password")
response = self.client.post(
"/api/v1/company/products/create/",
data=json.dumps({"code": "academy", "name": "Academy"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
@keyframes hubBlink{0%,100%{opacity:1;}50%{opacity:0;}}
</style>
</helmet>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f0f2f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#262626;-webkit-font-smoothing:antialiased;padding:24px;">
<div style="width:400px;max-width:100%;">
<div style="display:flex;flex-direction:column;align-items:center;margin-bottom:24px;">
<div style="width:48px;height:48px;border-radius:13px;background:#1677ff;display:flex;align-items:center;justify-content:center;margin-bottom:14px;box-shadow:0 4px 12px rgba(22,119,255,0.3);"><svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#fff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 4 5v6c0 5 3.4 8.5 8 10 4.6-1.5 8-5 8-10V5Z"/><path d="m9 12 2 2 4-4"/></svg></div>
<h1 style="margin:0;font-size:20px;font-weight:700;letter-spacing:-0.01em;color:#1f1f1f;">Двухфакторная проверка</h1>
<p style="margin:5px 0 0;font-size:13px;color:#8c8c8c;text-align:center;line-height:1.5;">Введите 6-значный код из приложения-аутентификатора</p>
</div>
<div style="background:#fff;border:1px solid #f0f0f0;border-radius:14px;box-shadow:0 2px 12px rgba(0,0,0,0.05);padding:26px;">
<div style="display:flex;align-items:center;gap:9px;padding:10px 13px;background:#f5f5f5;border-radius:9px;margin-bottom:22px;">
<div style="width:28px;height:28px;border-radius:50%;background:#1677ff;color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;flex:none;">ИП</div>
<span style="font-size:12.5px;color:#595959;">Вход как <b style="color:#262626;">ivan@edevs.tech</b> · OWNER</span>
</div>
<!-- code inputs -->
<div style="display:flex;justify-content:space-between;gap:9px;margin-bottom:14px;">
<sc-for list="{{ cells }}" as="c" hint-placeholder-count="6">
<div style="{{ c.style }}">{{ c.char }}<sc-if value="{{ c.cursor }}" hint-placeholder-val="{{ false }}"><span style="width:2px;height:22px;background:#1677ff;animation:hubBlink 1s step-end infinite;"></span></sc-if></div>
</sc-for>
</div>
<sc-if value="{{ hasError }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="#cf1322" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg><span style="font-size:12px;color:#cf1322;">Неверный код. Осталось попыток: 4</span></div>
</sc-if>
<!-- hidden real input for typing -->
<input value="{{ code }}" onInput="{{ onInput }}" inputmode="numeric" maxlength="6" placeholder="Нажмите и введите код" style="width:100%;height:40px;padding:0 13px;border:1px solid #e8e8e8;border-radius:9px;font-size:13px;color:#595959;font-family:inherit;outline:none;text-align:center;letter-spacing:1px;margin-bottom:18px;" style-focus="border-color:#1677ff;" />
<button onClick="{{ onSubmit }}" style="{{ submitStyle }}">Подтвердить</button>
<div style="display:flex;align-items:center;justify-content:center;gap:6px;margin-top:16px;font-size:12px;color:#8c8c8c;">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="#bfbfbf" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Код обновляется в приложении каждые 30 секунд
</div>
</div>
<div style="text-align:center;margin-top:18px;"><a href="#" style="font-size:12.5px;color:#1677ff;text-decoration:none;font-weight:500;" style-hover="text-decoration:underline;">Использовать резервный код</a></div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{
&quot;$preview&quot;: { &quot;width&quot;: 1440, &quot;height&quot;: 900 }
}">
class Component extends DCLogic {
state = { code: '', error: false };
renderVals() {
const code = this.state.code;
const cells = Array.from({ length: 6 }).map((_, i) => {
const char = code[i] || '';
const filled = !!char;
const active = i === code.length;
const base = 'flex:1;height:54px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:700;color:#1f1f1f;';
const style = this.state.error
? base + 'border:1.5px solid #ffccc7;background:#fff8f7;'
: filled
? base + 'border:1.5px solid #1677ff;background:#f7fbff;'
: active
? base + 'border:1.5px solid #1677ff;background:#fff;'
: base + 'border:1.5px solid #e8e8e8;background:#fafafa;';
return { char, cursor: active && char === '', style };
});
const complete = code.length === 6;
const submitStyle = complete
? 'width:100%;height:44px;border-radius:9px;border:none;background:#1677ff;color:#fff;font-size:14.5px;font-weight:600;cursor:pointer;box-shadow:0 1px 2px rgba(22,119,255,0.3);'
: 'width:100%;height:44px;border-radius:9px;border:none;background:#f0f0f0;color:#bfbfbf;font-size:14.5px;font-weight:600;cursor:not-allowed;';
return {
code, cells, hasError: this.state.error, submitStyle,
onInput: (e) => this.setState({ code: e.target.value.replace(/[^0-9]/g, '').slice(0, 6), error: false }),
onSubmit: () => { if (complete) this.setState({ error: true }); },
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
</style>
</helmet>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f0f2f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#262626;-webkit-font-smoothing:antialiased;padding:24px;">
<div style="width:400px;max-width:100%;">
<div style="display:flex;flex-direction:column;align-items:center;margin-bottom:24px;">
<div style="width:48px;height:48px;border-radius:13px;background:#1677ff;display:flex;align-items:center;justify-content:center;margin-bottom:14px;box-shadow:0 4px 12px rgba(22,119,255,0.3);"><svg viewBox="0 0 24 24" width="25" height="25" fill="none" stroke="#fff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 4 5v6c0 5 3.4 8.5 8 10 4.6-1.5 8-5 8-10V5Z"/><path d="M9 12l2 2 4-4"/></svg></div>
<h1 style="margin:0;font-size:20px;font-weight:700;letter-spacing:-0.01em;color:#1f1f1f;">Подтверждение входа</h1>
<p style="margin:5px 0 0;font-size:13px;color:#8c8c8c;text-align:center;max-width:330px;line-height:1.45;">Введите 6-значный код из приложения-аутентификатора для <b style="color:#595959;">ivan@edevs.tech</b></p>
</div>
<div style="background:#fff;border:1px solid #f0f0f0;border-radius:14px;box-shadow:0 2px 12px rgba(0,0,0,0.05);padding:28px 26px 24px;">
<!-- 6 code cells -->
<div style="display:flex;gap:9px;justify-content:center;margin-bottom:18px;">
<sc-for list="{{ cells }}" as="c" hint-placeholder-count="6">
<div style="width:48px;height:56px;border:1.5px solid {{ c.border }};border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:24px;font-weight:700;color:#1f1f1f;background:{{ c.bg }};font-family:'SF Mono',ui-monospace,Menlo,monospace;">{{ c.val }}</div>
</sc-for>
</div>
<sc-if value="{{ hasError }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;align-items:center;gap:9px;padding:10px 13px;background:#fff2f0;border:1px solid #ffccc7;border-radius:9px;margin-bottom:16px;">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#cf1322" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span style="font-size:12.5px;color:#cf1322;">Неверный код. Осталось попыток: 2</span>
</div>
</sc-if>
<!-- numpad-style helper input -->
<input value="{{ code }}" onInput="{{ onInput }}" inputmode="numeric" maxlength="6" placeholder="Введите код" style="width:100%;height:42px;padding:0 13px;border:1px solid #e8e8e8;border-radius:9px;font-size:15px;letter-spacing:3px;color:#262626;font-family:'SF Mono',ui-monospace,Menlo,monospace;outline:none;text-align:center;margin-bottom:18px;" style-focus="border-color:#1677ff;" />
<button onClick="{{ onSubmit }}" style="{{ submitStyle }}">Подтвердить</button>
<div style="display:flex;align-items:center;justify-content:center;gap:6px;margin-top:16px;font-size:12.5px;color:#8c8c8c;">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="#bfbfbf" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Код обновится через <span style="color:#595959;font-weight:600;font-variant-numeric:tabular-nums;">{{ countdown }}</span>
</div>
</div>
<p style="text-align:center;margin:18px 0 0;font-size:12px;color:#bfbfbf;">Нет доступа к коду? <a href="#" style="color:#1677ff;text-decoration:none;font-weight:500;" style-hover="text-decoration:underline;">Связаться с поддержкой</a></p>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{
&quot;$preview&quot;: { &quot;width&quot;: 1440, &quot;height&quot;: 900 }
}">
class Component extends DCLogic {
state = { code: '', error: false };
componentDidMount() {
this._t = 24;
this._iv = setInterval(() => {
this._t = this._t <= 1 ? 30 : this._t - 1;
this.forceUpdate();
}, 1000);
}
componentWillUnmount() { clearInterval(this._iv); }
renderVals() {
const code = this.state.code.replace(/\D/g, '').slice(0, 6);
const err = this.state.error;
const cells = Array.from({ length: 6 }).map((_, i) => {
const val = code[i] || '';
const active = i === code.length;
return {
val: val || '',
border: err ? '#ffccc7' : active ? '#1677ff' : val ? '#bfbfbf' : '#e8e8e8',
bg: val ? '#fafbff' : '#fff',
};
});
const valid = code.length === 6;
const submitStyle = valid
? 'width:100%;height:44px;border-radius:9px;border:none;background:#1677ff;color:#fff;font-size:14.5px;font-weight:600;cursor:pointer;box-shadow:0 1px 2px rgba(22,119,255,0.3);'
: 'width:100%;height:44px;border-radius:9px;border:none;background:#f0f0f0;color:#bfbfbf;font-size:14.5px;font-weight:600;cursor:not-allowed;';
const t = this._t == null ? 24 : this._t;
const countdown = '0:' + String(t).padStart(2, '0');
return {
cells, code, hasError: err, submitStyle, countdown,
onInput: (e) => this.setState({ code: e.target.value.replace(/\D/g, '').slice(0, 6), error: false }),
onSubmit: () => { if (code.length === 6) this.setState({ error: true }); },
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
</style>
</helmet>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f0f2f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#262626;-webkit-font-smoothing:antialiased;padding:24px;">
<div style="width:420px;max-width:100%;">
<div style="display:flex;flex-direction:column;align-items:center;margin-bottom:24px;">
<div style="width:48px;height:48px;border-radius:13px;background:#1677ff;display:flex;align-items:center;justify-content:center;margin-bottom:14px;box-shadow:0 4px 12px rgba(22,119,255,0.3);"><svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l2 6 4-14 2 8h6"/></svg></div>
<h1 style="margin:0;font-size:20px;font-weight:700;letter-spacing:-0.01em;color:#1f1f1f;">Смена временного пароля</h1>
<p style="margin:5px 0 0;font-size:13px;color:#8c8c8c;text-align:center;max-width:320px;line-height:1.45;">Вы вошли по временному паролю. Задайте постоянный пароль, чтобы продолжить.</p>
</div>
<div style="background:#fff;border:1px solid #f0f0f0;border-radius:14px;box-shadow:0 2px 12px rgba(0,0,0,0.05);padding:26px;">
<label style="display:block;font-size:12.5px;color:#595959;margin-bottom:7px;font-weight:500;">Новый пароль</label>
<div style="display:flex;align-items:center;gap:9px;height:42px;padding:0 13px;border:1px solid #e8e8e8;border-radius:9px;background:#fff;margin-bottom:14px;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#bfbfbf" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<input type="{{ pwdType }}" value="{{ pwd }}" onInput="{{ onPwd }}" placeholder="Минимум 10 символов" style="border:none;outline:none;background:transparent;font-size:14px;color:#262626;width:100%;font-family:inherit;" />
<button onClick="{{ onToggle }}" style="border:none;background:transparent;cursor:pointer;color:#bfbfbf;display:flex;flex:none;padding:0;" style-hover="color:#595959;">{{ eyeIcon }}</button>
</div>
<!-- strength -->
<div style="display:flex;gap:5px;margin-bottom:7px;">
<div style="flex:1;height:4px;border-radius:3px;background:{{ bar1 }};"></div>
<div style="flex:1;height:4px;border-radius:3px;background:{{ bar2 }};"></div>
<div style="flex:1;height:4px;border-radius:3px;background:{{ bar3 }};"></div>
<div style="flex:1;height:4px;border-radius:3px;background:{{ bar4 }};"></div>
</div>
<div style="font-size:11.5px;color:{{ strengthColor }};margin-bottom:18px;font-weight:500;">{{ strengthLabel }}</div>
<label style="display:block;font-size:12.5px;color:#595959;margin-bottom:7px;font-weight:500;">Повторите пароль</label>
<div style="display:flex;align-items:center;gap:9px;height:42px;padding:0 13px;border:1px solid {{ confirmBorder }};border-radius:9px;background:#fff;margin-bottom:8px;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#bfbfbf" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<input type="{{ pwdType }}" value="{{ confirm }}" onInput="{{ onConfirm }}" placeholder="Повторите новый пароль" style="border:none;outline:none;background:transparent;font-size:14px;color:#262626;width:100%;font-family:inherit;" />
</div>
<sc-if value="{{ mismatch }}" hint-placeholder-val="{{ false }}"><div style="font-size:11.5px;color:#cf1322;margin-bottom:14px;">Пароли не совпадают</div></sc-if>
<!-- requirements -->
<div style="background:#fafafa;border:1px solid #f0f0f0;border-radius:9px;padding:13px 15px;margin:14px 0 20px;">
<div style="font-size:11.5px;font-weight:600;color:#8c8c8c;margin-bottom:9px;">ТРЕБОВАНИЯ К ПАРОЛЮ</div>
<sc-for list="{{ reqs }}" as="r" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:9px;padding:3px 0;"><span style="flex:none;">{{ r.icon }}</span><span style="font-size:12.5px;color:{{ r.color }};">{{ r.label }}</span></div>
</sc-for>
</div>
<button onClick="{{ onSubmit }}" style="{{ submitStyle }}">Сохранить и войти</button>
</div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{
&quot;$preview&quot;: { &quot;width&quot;: 1440, &quot;height&quot;: 900 }
}">
class Component extends DCLogic {
state = { pwd: '', confirm: '', show: false };
renderVals() {
const eye = (open) => open
? React.createElement('svg', { viewBox: '0 0 24 24', width: 16, height: 16, fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' }, React.createElement('path', { key: 1, d: 'M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z' }), React.createElement('circle', { key: 2, cx: 12, cy: 12, r: 3 }))
: React.createElement('svg', { viewBox: '0 0 24 24', width: 16, height: 16, fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' }, React.createElement('path', { key: 1, d: 'M9.9 4.2A9.1 9.1 0 0 1 12 4c7 0 10 8 10 8a13 13 0 0 1-1.7 2.7M6.6 6.6A13 13 0 0 0 2 12s3 8 10 8a9 9 0 0 0 3.4-.6' }), React.createElement('path', { key: 2, d: 'M9.9 9.9a3 3 0 0 0 4.2 4.2' }), React.createElement('line', { key: 3, x1: 2, y1: 2, x2: 22, y2: 22 }));
const okIcon = React.createElement('svg', { viewBox: '0 0 24 24', width: 14, height: 14, fill: 'none', stroke: '#389e0d', strokeWidth: 2.4, strokeLinecap: 'round', strokeLinejoin: 'round' }, React.createElement('path', { d: 'M20 6 9 17l-5-5' }));
const dotIcon = React.createElement('svg', { viewBox: '0 0 24 24', width: 14, height: 14, fill: 'none', stroke: '#bfbfbf', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' }, React.createElement('circle', { cx: 12, cy: 12, r: 9 }));
const pwd = this.state.pwd, confirm = this.state.confirm;
const hasLen = pwd.length >= 10;
const hasNum = /\d/.test(pwd);
const hasSpecial = /[^A-Za-z0-9]/.test(pwd) && /[A-Za-z]/.test(pwd);
const score = [hasLen, hasNum, hasSpecial].filter(Boolean).length + (pwd.length >= 14 ? 1 : 0);
const reqs = [
{ ok: hasLen, label: 'Не менее 10 символов' },
{ ok: hasNum, label: 'Содержит цифру' },
{ ok: hasSpecial, label: 'Буквы и спецсимвол' },
].map((r) => ({ label: r.label, icon: r.ok ? okIcon : dotIcon, color: r.ok ? '#389e0d' : '#8c8c8c' }));
const palette = ['#f0f0f0', '#ff4d4f', '#faad14', '#52c41a', '#389e0d'];
const bar = (i) => score >= i ? (score <= 1 ? '#ff4d4f' : score === 2 ? '#faad14' : '#52c41a') : '#f0f0f0';
const strengthLabel = pwd.length === 0 ? 'Введите новый пароль' : score <= 1 ? 'Слабый пароль' : score === 2 ? 'Средний пароль' : score === 3 ? 'Хороший пароль' : 'Надёжный пароль';
const strengthColor = pwd.length === 0 ? '#bfbfbf' : score <= 1 ? '#cf1322' : score === 2 ? '#d48806' : '#389e0d';
const valid = hasLen && hasNum && hasSpecial && confirm.length > 0 && pwd === confirm;
const mismatch = confirm.length > 0 && pwd !== confirm;
const submitStyle = valid
? 'width:100%;height:44px;border-radius:9px;border:none;background:#1677ff;color:#fff;font-size:14.5px;font-weight:600;cursor:pointer;box-shadow:0 1px 2px rgba(22,119,255,0.3);'
: 'width:100%;height:44px;border-radius:9px;border:none;background:#f0f0f0;color:#bfbfbf;font-size:14.5px;font-weight:600;cursor:not-allowed;';
return {
pwd, confirm, pwdType: this.state.show ? 'text' : 'password', eyeIcon: eye(this.state.show),
bar1: bar(1), bar2: bar(2), bar3: bar(3), bar4: bar(4), strengthLabel, strengthColor,
reqs, mismatch, confirmBorder: mismatch ? '#ffccc7' : '#e8e8e8', submitStyle,
onPwd: (e) => this.setState({ pwd: e.target.value }),
onConfirm: (e) => this.setState({ confirm: e.target.value }),
onToggle: () => this.setState((s) => ({ show: !s.show })),
onSubmit: () => {},
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,362 @@
# SPEC-HUB-0003 — Web Chat
## 1. Назначение
Документ определяет функциональные требования к Web Chat первой рабочей итерации Hub.
Web Chat — постоянный продуктовый мессенджер, встроенный в сайты Edevs и работающий через общий Communications-контур вместе с MAX и Telegram.
Связанные решения:
- `ADR-HUB-0002-Conversation-State-and-Lifecycle.md`;
- `ADR-HUB-0003-AI-First-and-Human-Takeover.md`;
- `ADR-HUB-0006-Channel-Identity-and-Contact-Merging.md`;
- `ADR-HUB-0011-Consent-and-LLM-Data-Minimization.md`;
- `ADR-HUB-0012-Persistent-Product-Web-Chat.md`.
## 2. Границы
Web Chat отвечает за:
- запуск виджета на сайте продукта;
- показ опубликованной конфигурации;
- браузерную сессию;
- согласие;
- отправку и получение сообщений;
- отображение состояния AI и оператора;
- передачу вложений;
- отображение оформления покупки;
- восстановление истории;
- понятное состояние недоступности для клиента.
Web Chat не отвечает за:
- отдельную клиентскую учётную запись Hub;
- тикеты;
- отдельную очередь операторов;
- отдельную модель диалога;
- оформление и подтверждение платежа;
- хранение продуктовых знаний;
- дерево сценариев чат-бота.
## 3. Подключение к сайту
Виджет подключается публичным JS-фрагментом.
Концептуальный пример:
```html
<script
src="https://chat.hub.edevs.tech/widget.js"
data-product="firepage"
data-widget-key="public-widget-key">
</script>
```
Точные имена URL и атрибутов определяются техническим планом, но контракт обязан передавать:
- код продукта;
- публичный ключ опубликованной конфигурации.
Требования:
- фрагмент не содержит секретов;
- Hub проверяет origin сайта;
- конфигурация загружается только для активного продукта и разрешённого домена;
- ошибка конфигурации не ломает остальную страницу;
- виджет загружается асинхронно;
- сбой виджета изолирован от интерфейса продукта.
## 4. Конфигурация продукта
OWNER управляет конфигурацией через UI Hub.
Обязательные поля:
- продукт;
- статус `DRAFT / PUBLISHED / DISABLED`;
- список разрешённых доменов;
- заголовок;
- логотип;
- приветствие;
- акцентный цвет;
- версия текста согласия;
- быстрые ответы;
- разрешённые типы вложений;
- лимит одного файла;
- резервные контакты MAX, Telegram или другой канал;
- anti-abuse параметры.
Изменение конфигурации не требует изменения кода сайта.
## 5. Клиентские состояния
### 5.1. Launcher
- расположен в правом нижнем углу по умолчанию;
- показывает индикатор непрочитанных сообщений;
- не перекрывает критические элементы страницы;
- позиция и отступы допускают настройку.
### 5.2. Приветствие и согласие
До принятия обязательного согласия показываются:
- название продукта;
- короткое приветствие;
- текст согласия или ссылка на полный текст;
- действие принятия.
До принятия согласия LLM не вызывается и коммерческий диалог не начинается.
### 5.3. Диалог с AI
Клиент видит:
- историю сообщений;
- поле свободного текста;
- быстрые ответы, когда они уместны;
- отправку изображения или файла;
- состояние отправки;
- checkout-карточку или кнопку покупки;
- понятное обозначение, что отвечает виртуальный помощник компании.
### 5.4. Подключение оператора
При передаче показывается сообщение о подключении специалиста и о том, что история уже доступна оператору.
Клиент не создаёт новый запрос и не повторяет данные.
### 5.5. Диалог с оператором
Используется та же история и то же поле ввода. Клиент видит, что отвечает специалист.
### 5.6. Временная недоступность
Виджет показывает:
- сообщение о недоступности;
- fallback-каналы из опубликованной конфигурации;
- отсутствие ложного подтверждения отправки.
## 6. Responsive behaviour
### Desktop
- компактная панель поверх страницы;
- не требует перехода на отдельный URL;
- допускает сворачивание без потери состояния.
### Mobile
- чат занимает почти весь viewport;
- учитывает safe areas и экранную клавиатуру;
- основное действие отправки остаётся доступным;
- закрытие возвращает пользователя на исходную страницу.
## 7. Сессия и история
### 7.1. Анонимная session
При первом открытии Hub выдаёт высокоэнтропийный непрозрачный `session_credential`. Сервер хранит только его hash. Виджет обменивает credential на короткоживущий access token для API и realtime-соединения. Access token поддерживает ротацию и отзыв.
Browser storage сохраняет session credential в контексте сайта продукта.
Одна session обеспечивает:
- переходы между страницами;
- перезагрузку;
- повторное посещение;
- восстановление текущего открытого диалога и доступной истории.
Session credential не является подтверждённой личностью.
### 7.2. Потеря session
При очистке browser storage или использовании другого устройства создаётся новая анонимная `ChannelIdentity`.
Автоматическое объединение с прежним контактом не выполняется.
### 7.3. Авторизованный пользователь
Контракт предусматривает последующую передачу подписанного identity token от внешней системы продукта.
В первой итерации поддержка такого token не является обязательным acceptance criterion.
### 7.4. Conversation lifecycle
- закрытие панели не закрывает диалог;
- переход и закрытие браузера не закрывают диалог;
- открытый диалог продолжается после восстановления session;
- после `CLOSED` новое сообщение создаёт новый `Conversation`;
- предыдущий диалог остаётся доступен в истории.
## 8. Сообщения
Поддерживаются:
- plain text;
- изображения;
- обычные файлы;
- быстрые ответы;
- системные сообщения;
- checkout-карточки.
Для каждого сообщения отображаются:
- автор;
- время;
- состояние отправки;
- ошибка при наличии.
Порядок сообщений должен быть устойчив к повторной доставке и переподключению.
## 9. Вложения
Виджет обязан:
- проверять разрешённый тип до загрузки;
- проверять размер;
- показывать прогресс;
- позволять отменить загрузку;
- показывать понятную ошибку;
- не отправлять файл в LLM автоматически, если это не разрешено инструментом и конфигурацией.
Конкретные MIME-типы и размеры являются конфигурацией запуска.
Неподдерживаемый контент создаёт системное событие и переводит диалог к оператору.
## 10. AI-first процесс
После согласия:
1. создаётся или восстанавливается `ChannelIdentity`;
2. определяется открытый `Conversation` либо создаётся новый;
3. клиентское сообщение сохраняется;
4. вызывается sales-агент продукта;
5. ответ отправляется в тот же диалог;
6. при необходимости создаётся checkout или запрос оператора.
AI использует актуальный `ProductAIRelease` и инструменты коммерческого ядра Hub.
## 11. Передача оператору
Передача должна быть бесшовной:
- используется текущий `Conversation`;
- AI-генерация отменяется атомарно;
- клиент видит состояние подключения;
- оператор видит всю историю и резюме;
- AI не отправляет клиенту новые сообщения после takeover;
- возврат к AI выполняется только явным действием оператора или OWNER.
## 12. Асинхронная работа
Клиент может отправить сообщение и покинуть сайт.
Требования:
- отправленное сообщение сохраняется сервером до подтверждения UI;
- уход со страницы не меняет lifecycle;
- ответ доступен после повторного открытия;
- launcher показывает непрочитанный ответ;
- отсутствие оператора отражается понятным статусом, но не блокирует AI-first обработку.
## 13. Checkout в диалоге
AI или оператор может отправить checkout-карточку.
Карточка показывает минимум:
- название предложения;
- актуальную цену;
- тип оплаты или период;
- основное действие «Перейти к покупке».
Цена и условия приходят из коммерческого ядра Hub. Виджет не рассчитывает стоимость и не принимает платёж внутри iframe.
## 14. Anti-abuse и безопасность
Обязательны:
- HTTPS;
- проверка origin;
- allowlist доменов;
- непрозрачный high-entropy session credential, hash-at-rest и короткоживущий access token;
- rate limit по session и IP;
- ограничение длины сообщения;
- ограничение вложений;
- duplicate/replay protection;
- блокировка параллельных отправок;
- защита публичных endpoints от enumeration;
- возможность progressive challenge;
- серверная проверка всех клиентских ограничений.
Публичный `widget_key` не предоставляет административный доступ и не является credential.
## 15. Privacy
- до согласия LLM не вызывается;
- LLM payload проходит redaction;
- session credential, access token и internal IDs не передаются LLM;
- данные карты не проходят через Web Chat;
- сведения checkout передаются модели только через разрешённые инструменты;
- тексты согласий версионируются.
## 16. Доступность и UX
Виджет должен:
- управляться клавиатурой;
- иметь видимый focus;
- содержать доступные labels для интерактивных элементов;
- корректно работать с масштабированием;
- не блокировать основную страницу при ошибке;
- не заставлять клиента заполнять обязательную анкету до первого вопроса;
- всегда позволять написать свободный текст, даже при наличии быстрых ответов.
## 17. Настраиваемые параметры запуска
Архитектура не фиксирует конкретные значения:
- приветствия;
- цветов;
- быстрых ответов;
- лимитов сообщений и файлов;
- таймаутов;
- anti-abuse порогов;
- fallback-контактов.
Они задаются конфигурацией продукта и отдельным launch configuration документом.
## 18. Не входит в первую итерацию
- аудио- и видеозвонки;
- голосовые сообщения как AI-контент;
- видеосообщения;
- реакции и стикеры;
- редактирование отправленных сообщений;
- cross-device continuity для анонимного клиента;
- обязательная авторизованная identity продукта;
- отдельный клиентский кабинет;
- email continuation;
- отдельная Web Chat очередь;
- визуальный конструктор сценариев.
## 19. Acceptance criteria
1. Виджет подключается к разрешённому домену одним JS-фрагментом.
2. Неразрешённый домен не получает рабочую конфигурацию.
3. До согласия LLM не вызывается.
4. После согласия клиент отправляет свободный текст и получает AI-ответ.
5. История сохраняется при переходе между страницами и перезагрузке.
6. Закрытие панели не закрывает `Conversation`.
7. После доменного закрытия новое сообщение создаёт новый `Conversation`.
8. Оператор перехватывает текущий диалог без нового клиентского чата и двойного ответа.
9. Клиент может отправить текст, изображение и разрешённый файл.
10. Быстрый ответ не блокирует свободный ввод.
11. Карточка покупки открывает публичное оформление покупки коммерческого ядра Hub.
12. Desktop и mobile состояния работоспособны.
13. При недоступности Hub виджет показывает fallback и не подтверждает ложную отправку.
14. Rate limit и серверные ограничения блокируют превышение настроенных порогов.
15. Web Chat сообщения видны в общем inbox рядом с MAX и Telegram.
+18 -7
View File
@@ -2,12 +2,17 @@ import type { ThemeConfig } from "antd";
export const edevsHubTheme: ThemeConfig = {
token: {
colorPrimary: "#2563eb",
colorSuccess: "#059669",
colorWarning: "#d97706",
colorError: "#dc2626",
colorInfo: "#2563eb",
colorPrimary: "#1677ff",
colorSuccess: "#52c41a",
colorWarning: "#faad14",
colorError: "#ff4d4f",
colorInfo: "#1677ff",
borderRadius: 8,
colorBgLayout: "#f0f2f5",
colorBgContainer: "#ffffff",
colorBorder: "#f0f0f0",
colorText: "#262626",
colorTextSecondary: "#8c8c8c",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
},
@@ -15,13 +20,19 @@ export const edevsHubTheme: ThemeConfig = {
Layout: {
headerBg: "#ffffff",
siderBg: "#ffffff",
bodyBg: "#f6f8fb",
bodyBg: "#f0f2f5",
},
Card: {
borderRadiusLG: 8,
},
Table: {
headerBg: "#f8fafc",
headerBg: "#fafafa",
headerColor: "#8c8c8c",
rowHoverBg: "#fafbfc",
},
Button: {
borderRadius: 8,
primaryShadow: "0 1px 2px rgba(22,119,255,0.3)",
},
},
};