mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
Implement internal Hub baseline and auth flow
This commit is contained in:
1 parent
0abc421766
commit
7d4c4ba6ef
17 files changed
+4685
-83
No files matched your search
@@ -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 "Иван Петров"
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
+1141
-43
File diff suppressed because it is too large.
Load diff
+2394
-28
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="{
|
||||
"$preview": { "width": 1440, "height": 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="{
|
||||
"$preview": { "width": 1440, "height": 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="{
|
||||
"$preview": { "width": 1440, "height": 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.
|
||||
@@ -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)",
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user