diff --git a/README.md b/README.md index ac5ed3b..45411df 100644 --- a/README.md +++ b/README.md @@ -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 "Иван Петров" ``` diff --git a/apps/backend/hub_backend/urls.py b/apps/backend/hub_backend/urls.py index 3588d8c..9b28ed4 100644 --- a/apps/backend/hub_backend/urls.py +++ b/apps/backend/hub_backend/urls.py @@ -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")), ] diff --git a/apps/backend/hub_platform/identity/auth_urls.py b/apps/backend/hub_platform/identity/auth_urls.py index 30ef111..2384088 100644 --- a/apps/backend/hub_platform/identity/auth_urls.py +++ b/apps/backend/hub_platform/identity/auth_urls.py @@ -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"), ] diff --git a/apps/backend/hub_platform/identity/auth_views.py b/apps/backend/hub_platform/identity/auth_views.py index dccc7c9..384f511 100644 --- a/apps/backend/hub_platform/identity/auth_views.py +++ b/apps/backend/hub_platform/identity/auth_views.py @@ -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)}) diff --git a/apps/backend/hub_platform/identity/company_urls.py b/apps/backend/hub_platform/identity/company_urls.py new file mode 100644 index 0000000..50eee06 --- /dev/null +++ b/apps/backend/hub_platform/identity/company_urls.py @@ -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//deactivate/", company_views.deactivate_product_view, name="product-deactivate"), +] diff --git a/apps/backend/hub_platform/identity/company_views.py b/apps/backend/hub_platform/identity/company_views.py new file mode 100644 index 0000000..c77e8e9 --- /dev/null +++ b/apps/backend/hub_platform/identity/company_views.py @@ -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)}) diff --git a/apps/backend/hub_platform/identity/migrations/0003_employeeprofile_totp_secret.py b/apps/backend/hub_platform/identity/migrations/0003_employeeprofile_totp_secret.py new file mode 100644 index 0000000..d72a46a --- /dev/null +++ b/apps/backend/hub_platform/identity/migrations/0003_employeeprofile_totp_secret.py @@ -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), + ), + ] diff --git a/apps/backend/hub_platform/identity/models.py b/apps/backend/hub_platform/identity/models.py index bfcb8aa..1548652 100644 --- a/apps/backend/hub_platform/identity/models.py +++ b/apps/backend/hub_platform/identity/models.py @@ -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) diff --git a/apps/backend/hub_platform/identity/tests.py b/apps/backend/hub_platform/identity/tests.py index 2615c51..d92f1c4 100644 --- a/apps/backend/hub_platform/identity/tests.py +++ b/apps/backend/hub_platform/identity/tests.py @@ -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) diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index 3503f5b..7809e1b 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -1,52 +1,1150 @@ -import { ApartmentOutlined, RobotOutlined, ShoppingOutlined, TeamOutlined } from "@ant-design/icons"; -import { ConfigProvider, Layout, Menu, Tag, Typography } from "antd"; +import { ConfigProvider } from "antd"; +import { FormEvent, ReactNode, useEffect, useMemo, useState } from "react"; + import { edevsHubTheme } from "@edevs/ui"; -const { Header, Sider, Content } = Layout; +const API_BASE = "http://localhost:8010"; + +type Role = "OWNER" | "OPERATOR"; +type ProductStatus = "ACTIVE" | "DISABLED"; + +type SessionUser = { + id: number; + email: string; + fullName: string; + role: Role; + organization: string; + organizationName: string; + department: string | null; + mustChangePassword: boolean; + totpRequired: boolean; + totpEnabled: boolean; +}; + +type AuthChallenge = { + email: string; + fullName: string; + role: Role; +}; + +type LoginPayload = + | { authenticated: true; user: SessionUser } + | { authenticated: false; totpRequired: true; totpEnabled: true; challenge: AuthChallenge }; + +type Employee = { + id: number; + email: string; + fullName: string; + role: Role; + department: string | null; + isActive: boolean; + isBlocked: boolean; + mustChangePassword: boolean; + totpRequired: boolean; + totpEnabled: boolean; +}; + +type Department = { + id: number; + code: string; + name: string; + status: string; + memberCount: number; + operatorCount: number; + activeOperatorCount: number; + products: Array<{ code: string; name: string }>; +}; + +type Product = { + id: number; + code: string; + name: string; + status: ProductStatus; + siteUrl: string; + createdAt: string; +}; + +type RouteKey = "command" | "departments" | "employees" | "products" | "profile"; + +type AppData = { + employees: Employee[]; + departments: Department[]; + products: Product[]; +}; + +const routes: Record = { + command: "Командный центр", + departments: "Отделы", + employees: "Сотрудники", + products: "Продукты", + profile: "Профиль", +}; + +function getCookie(name: string): string { + const cookie = document.cookie + .split("; ") + .find((item) => item.startsWith(`${name}=`)); + return cookie ? decodeURIComponent(cookie.split("=")[1]) : ""; +} + +async function api(path: string, init: RequestInit = {}): Promise { + const method = init.method ?? "GET"; + const headers = new Headers(init.headers); + headers.set("Accept", "application/json"); + if (method !== "GET") { + headers.set("Content-Type", "application/json"); + headers.set("X-CSRFToken", getCookie("csrftoken")); + } + const response = await fetch(`${API_BASE}${path}`, { + ...init, + credentials: "include", + headers, + }); + if (!response.ok) { + const payload = await response.json().catch(() => ({ detail: "Ошибка запроса" })); + throw new Error(payload.detail ?? "Ошибка запроса"); + } + return response.json() as Promise; +} + +function initials(name: string, email: string): string { + const source = name.trim() || email.split("@")[0] || "EH"; + const parts = source.split(/\s+/).filter(Boolean); + if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + return source.slice(0, 2).toUpperCase(); +} + +function productAccent(code: string): { bg: string; color: string } { + if (code === "foxray") return { bg: "#f9f0ff", color: "#722ed1" }; + return { bg: "#e6f4ff", color: "#0958d9" }; +} + +function PulseIcon() { + return ( + + + + ); +} + +function ShieldIcon() { + return ( + + + + + ); +} + +function Icon({ name, size = 17 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "chevron" | "plus" | "arrow" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "warning" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "percent"; size?: number }) { + const common = { width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" } as const; + const paths: Record = { + grid: <>, + building: <>, + team: <>, + box: <>, + robot: <>, + plug: <>, + settings: <>, + bell: <>, + chevron: , + plus: <>, + arrow: <>, + refresh: <>, + warning: <>, + bolt: , + shop: <>, + search: <>, + more: <>, + external: <>, + key: <>, + pause: <>, + percent: <>, + lock: <>, + mail: <>, + eye: <>, + eyeOff: <>, + logout: <>, + user: <>, + }; + return {paths[name]}; +} + +function AuthFrame({ title, subtitle, logo, width = 400, children, note }: { title: string; subtitle: string; logo: "pulse" | "shield"; width?: number; children: ReactNode; note?: ReactNode }) { + return ( +
+
+
+
{logo === "shield" ? : }
+

{title}

+

{subtitle}

+
+ {children} + {note &&
{note}
} +
+
+ ); +} + +function AuthCodeCells({ value }: { value: string }) { + const digits = value.padEnd(6, " ").slice(0, 6).split(""); + return
{digits.map((digit, index) => {digit})}
; +} + +function AuthLogin({ onLogin, onTotpChallenge }: { onLogin: (user: SessionUser) => void; onTotpChallenge: (challenge: AuthChallenge) => void }) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [show, setShow] = useState(false); + const [error, setError] = useState(false); + const [submitting, setSubmitting] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(false); + try { + const payload = await api("/api/v1/auth/login/", { + method: "POST", + body: JSON.stringify({ email, password }), + }); + if (payload.authenticated) { + onLogin(payload.user); + } else { + onTotpChallenge(payload.challenge); + } + } catch { + setError(true); + } finally { + setSubmitting(false); + } + } + + return ( + +
+ {error && ( +
+ ! + Неверный email или пароль. Проверьте данные и попробуйте снова. +
+ )} + +
+ + { setEmail(event.target.value); setError(false); }} placeholder="you@edevs.tech" /> +
+ +
+ + { setPassword(event.target.value); setError(false); }} placeholder="Пароль" /> + +
+ +
+
+ ); +} + +function passwordScore(password: string): number { + if (!password) return 0; + let score = password.length >= 10 ? 1 : 0; + if (/\d/.test(password)) score += 1; + if (/[A-Za-zА-Яа-я]/.test(password)) score += 1; + if (/[^A-Za-zА-Яа-я0-9]/.test(password)) score += 1; + return Math.min(score, 4); +} + +function AuthChangePassword({ onChanged }: { user: SessionUser; onChanged: (user: SessionUser) => void }) { + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + const score = passwordScore(password); + const mismatch = confirm.length > 0 && password !== confirm; + const valid = password.length >= 10 && /\d/.test(password) && /[A-Za-zА-Яа-я]/.test(password) && /[^A-Za-zА-Яа-я0-9]/.test(password) && !mismatch; + const labels = ["Введите новый пароль", "Слабый пароль", "Средний пароль", "Хороший пароль", "Надёжный пароль"]; + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!valid) return; + setSubmitting(true); + setError(""); + try { + const payload = await api<{ authenticated: true; user: SessionUser }>("/api/v1/auth/change-temporary-password/", { + method: "POST", + body: JSON.stringify({ newPassword: password }), + }); + onChanged(payload.user); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "Не удалось сохранить пароль"); + } finally { + setSubmitting(false); + } + } + + return ( + +
+ {error &&
!{error}
} + +
+ + setPassword(event.target.value)} placeholder="Минимум 10 символов" /> +
+
+
{[0, 1, 2, 3].map((item) => )}
+

{labels[score]}

+
+ +
+ + setConfirm(event.target.value)} placeholder="Повторите новый пароль" /> +
+ {mismatch &&
Пароли не совпадают
} +
+ ТРЕБОВАНИЯ К ПАРОЛЮ + = 10 ? "done" : ""}>Не менее 10 символов + Содержит цифру + Буквы и спецсимвол +
+ +
+
+ ); +} + +function AuthTotpSetup({ user, onConfirmed }: { user: SessionUser; onConfirmed: (user: SessionUser) => void }) { + const [secret, setSecret] = useState(""); + const [code, setCode] = useState(""); + const [error, setError] = useState(false); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + api<{ secret: string }>("/api/v1/auth/totp/setup/") + .then((payload) => setSecret(payload.secret)) + .catch(() => setError(true)); + }, []); + + async function submit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(false); + try { + const payload = await api<{ authenticated: true; user: SessionUser }>("/api/v1/auth/totp/confirm/", { + method: "POST", + body: JSON.stringify({ code }), + }); + onConfirmed(payload.user); + } catch { + setError(true); + } finally { + setSubmitting(false); + } + } + + return ( + +
+
+ {initials(user.fullName, user.email)} +

Вход как {user.email} · {user.role}

+
+ {!user.totpEnabled && secret &&
Ключ настройки{secret}
} + + {error &&
Неверный код. Осталось попыток: 4
} + { setCode(event.target.value.replace(/\D/g, "").slice(0, 6)); setError(false); }} placeholder="Нажмите и введите код" inputMode="numeric" autoFocus /> + +
Код обновляется в приложении каждые 30 секунд
+ + +
+ ); +} + +function AuthTotpCode({ challenge, onVerified }: { challenge: AuthChallenge; onVerified: (user: SessionUser) => void }) { + const [code, setCode] = useState(""); + const [error, setError] = useState(false); + const [submitting, setSubmitting] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(false); + try { + const payload = await api<{ authenticated: true; user: SessionUser }>("/api/v1/auth/totp/verify/", { + method: "POST", + body: JSON.stringify({ code }), + }); + onVerified(payload.user); + } catch { + setError(true); + } finally { + setSubmitting(false); + } + } + + return ( + +
+ {error &&
!Неверный код. Осталось попыток: 2
} + + { setCode(event.target.value.replace(/\D/g, "").slice(0, 6)); setError(false); }} placeholder="Введите код" inputMode="numeric" autoFocus /> + +
Код обновится через 0:24
+ + +
+ ); +} + +function Sidebar({ route, user, setRoute }: { route: RouteKey; user: SessionUser; setRoute: (route: RouteKey) => void }) { + const nav = [ + { key: "command" as const, label: "Командный центр", icon: "grid" as const }, + { label: "КОМПАНИЯ", group: true }, + { key: "departments" as const, label: "Отделы", icon: "building" as const }, + { key: "employees" as const, label: "Сотрудники", icon: "team" as const }, + { key: "products" as const, label: "Продукты", icon: "box" as const }, + { label: "ПЛАТФОРМА", group: true }, + { label: "AI", icon: "robot" as const, disabled: true }, + { label: "Интеграции", icon: "plug" as const, disabled: true }, + { divider: true }, + { label: "Настройки", icon: "settings" as const, disabled: true }, + ]; + return ( + + ); +} + +function Avatar({ user, employee }: { user?: SessionUser; employee?: Employee }) { + const label = employee ? initials(employee.fullName, employee.email) : initials(user?.fullName ?? "", user?.email ?? ""); + return {label}; +} + +function TopBar({ route, user, onLogout }: { route: RouteKey; user: SessionUser; onLogout: () => void }) { + const st = commandCenterModel("today").st; + const isCommand = route === "command"; + return ( +
+
{user.organizationName}/{routes[route]}
+
+ {isCommand && {st.label}} + + + +
+
+ ); +} + +function Shell({ route, setRoute, user, data, reload, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; user: SessionUser; data: AppData; reload: () => void; onLogout: () => void }) { + return ( +
+ +
+ +
+
+ {route === "command" && } + {route === "departments" && } + {route === "employees" && } + {route === "products" && } + {route === "profile" && } +
+
+
+
+ ); +} + +function PageHeader({ title, text, action }: { title: string; text?: ReactNode; action?: ReactNode }) { + return ( +
+

{title}

{text &&

{text}

}
+ {action} +
+ ); +} + +function StatusPill({ status }: { status: "normal" | "active" | "blocked" | "disabled" | "invited" }) { + const map = { + normal: ["#f6ffed", "#b7eb8f", "#389e0d", "NORMAL"], + active: ["transparent", "transparent", "#389e0d", "Активен"], + blocked: ["transparent", "transparent", "#cf1322", "Заблокирован"], + disabled: ["transparent", "transparent", "#d48806", "Неактивен"], + invited: ["transparent", "transparent", "#0958d9", "Приглашён"], + } as const; + const [bg, border, color, label] = map[status]; + return {label}; +} + +function CommandCenter({ data, setRoute }: { data: AppData; setRoute: (route: RouteKey) => void }) { + const [period, setPeriod] = useState<"today" | "d7" | "d30">("today"); + const vm = commandCenterModel(period); + return ( + <> +
+
+

Командный центр

+

Состояние компании одним взглядом · обновлено только что

+
+
+ + +
+
+ +
+
+
+ Компания: {vm.st.label} +

{vm.compSummary}

+
+
+ + + +
+
+ +
+
+
+

Отделы

+ 1 активный +
+
+
+
+
+
+

Продажи

+ +
+

Ответственный: Анна Котова · 4 сотрудника · 1 AI-агент

+
+ +
+
{vm.deptSummary}
+ + +
+
+ + +
+
Обновлено: сегодня, 14:32 · детерминированная сводка
+ + ); +} + +type CommandVm = ReturnType; + +function commandCenterModel(period: "today" | "d7" | "d30") { + const fmt = (n: number) => `₽${Math.round(n).toLocaleString("ru-RU").replace(/\u00a0/g, " ")}`; + const ops = { open: 42, active: 9, ai: 35, op: 7, wait: 0, pend: 1, ferr: 0 }; + const com = { + today: { label: "Сегодня", sales: 18, rev: 146200, aiSpend: 1240, budget: 5000, tokens: "412K", dlg: 42 }, + d7: { label: "7 дней", sales: 126, rev: 1024600, aiSpend: 8600, budget: 35000, tokens: "2,9M", dlg: 318 }, + d30: { label: "30 дней", sales: 540, rev: 4386000, aiSpend: 36400, budget: 150000, tokens: "12,4M", dlg: 1342 }, + }[period]; + const st = { label: "Нормально", color: "#389e0d", bg: "#f6ffed", border: "#b7eb8f", dot: "#52c41a" }; + const m = { + open: String(ops.open), + active: String(ops.active), + ai: String(ops.ai), + op: String(ops.op), + wait: String(ops.wait), + pend: String(ops.pend), + ferr: String(ops.ferr), + sales: String(com.sales), + rev: fmt(com.rev), + waitColor: "#262626", + pendColor: "#262626", + ferrColor: "#262626", + }; + const attention = [ + { dot: "#faad14", title: "Незавершённый платёж · заказ ORD-10482", meta: "Продажи · Точка", time: "6 мин" }, + { dot: "#1677ff", title: "Подписка истекает через 2 дня · Foxray Pro", meta: "Продажи", time: "1 ч" }, + ]; + const integrations = [ + { name: "OpenRouter", group: "AI-провайдер", dot: "#52c41a", statusLabel: "Подключено", statusColor: "#52c41a" }, + { name: "Точка", group: "Платежи и фискализация", dot: "#52c41a", statusLabel: "Подключено", statusColor: "#52c41a" }, + { name: "MAX", group: "Канал", dot: "#52c41a", statusLabel: "Подключено", statusColor: "#52c41a" }, + { name: "Telegram", group: "Канал", dot: "#52c41a", statusLabel: "Подключено", statusColor: "#52c41a" }, + { name: "Web Chat", group: "Канал", dot: "#52c41a", statusLabel: "Подключено", statusColor: "#52c41a" }, + { name: "Fulfillment · FirePage", group: "Исполнение", dot: "#52c41a", statusLabel: "Подключено", statusColor: "#52c41a" }, + ]; + const aiPct = Math.round((com.aiSpend / com.budget) * 100); + const aiBarColor = aiPct >= 85 ? "#ff4d4f" : aiPct >= 70 ? "#faad14" : "#1677ff"; + return { + st, + m, + compSummary: "Все системы в норме. Один отдел активен, критичных событий нет.", + deptSummary: "AI ведёт большинство диалогов. Очередь оператора пуста, незавершённых задач почти нет.", + attention, + integrations, + okCount: "6", + intHeadColor: "#389e0d", + periodLabel: com.label, + periodLabelUpper: com.label.toUpperCase(), + aiSpendStr: fmt(com.aiSpend), + budgetStr: fmt(com.budget), + aiPct, + aiBarColor, + aiTokens: com.tokens, + aiDialogs: String(com.dlg), + costPerDialog: fmt(com.aiSpend / com.dlg), + }; +} + +function StatusLabel({ vm }: { vm: CommandVm }) { + return ( + + + {vm.st.label} + + ); +} + +function SmallMetric({ label, value, success = false }: { label: string; value: string; success?: boolean }) { + return
{label}{value}
; +} + +function MetricGroup({ title, columns, items }: { title: string; columns: 4 | 5; items: Array<{ label: string; value: string; color?: string; dot?: string }> }) { + return ( + <> +
{title}
+
+ {items.map((item) => ( +
+
{item.dot && }{item.label}
+ {item.value} +
+ ))} +
+ + ); +} + +function RailCard({ title, icon, iconColor, count, action, side, children }: { title: string; icon: "warning" | "plug"; iconColor: string; count?: string; action?: string; side?: ReactNode; children: ReactNode }) { + return ( +
+
+
{title}{count && {count}}
+ {action && event.preventDefault()}>{action}} + {side} +
+
{children}
+
+ ); +} + +function SmallAiMetric({ label, value }: { label: string; value: string }) { + return
{label}{value}
; +} + +function MetricCard({ label, value, sub, compact = false }: { label: string; value: number | string; sub: string; compact?: boolean }) { + return
{label}{value}{sub}
; +} + +function DepartmentsPage({ data, setRoute }: { data: AppData; setRoute: (route: RouteKey) => void }) { + const sales = data.departments.find((department) => department.code === "sales") ?? data.departments[0]; + return ( + <> + + {sales && ( +
+
+
+
+
+
+

{sales.name}

+ +
+

AI ведёт большинство диалогов, очередь оператора пуста.

+
+
+ +
+
+ Ответственный + АКАнна Котова +
+
+ Состав + 4 сотрудника · 1 AI-агент +
+
+ Связанные продукты + {data.products.map((product) => )} +
+
+ +
+
Открытые диалоги42
+
Продажи · сегодня18
+
Выручка₽146 200
+
+ + +
+
+ )} +

Новые отделы появятся здесь по мере их создания.

+ + ); +} + +function EmployeesPage({ employees, reload }: { employees: Employee[]; reload: () => void }) { + const [role, setRole] = useState<"all" | Role>("all"); + const [status, setStatus] = useState<"all" | "active" | "invited" | "blocked">("all"); + const [query, setQuery] = useState(""); + const [menuId, setMenuId] = useState(null); + const filtered = employees.filter((employee) => { + const employeeStatus = employeeStatusKey(employee); + const q = query.trim().toLowerCase(); + return ( + (role === "all" || employee.role === role) && + (status === "all" || employeeStatus === status) && + (!q || employee.fullName.toLowerCase().includes(q) || employee.email.toLowerCase().includes(q)) + ); + }); + async function block(employee: Employee) { + if (employee.role === "OWNER" || employee.isBlocked) return; + await api(`/api/v1/employees/${employee.id}/block/`, { method: "POST" }); + setMenuId(null); + reload(); + } + const resetFilters = () => { + setQuery(""); + setRole("all"); + setStatus("all"); + setMenuId(null); + }; + return ( + <> + {menuId !== null && } + /> +
+ +
+ Роль + { setRole(nextRole); setMenuId(null); }} items={[["all", "Все"], ["OWNER", "OWNER"], ["OPERATOR", "OPERATOR"]]} /> +
+
+ Статус + { setStatus(nextStatus); setMenuId(null); }} items={[["all", "Все"], ["active", "Активные"], ["invited", "Приглашённые"], ["blocked", "Заблокированные"]]} /> +
+ +
+
+
+ + + + {filtered.map((employee) => )} + +
СОТРУДНИКРОЛЬОТДЕЛСТАТУСПОСЛЕДНИЙ ВХОДДИАЛОГИ
+
+ {!filtered.length && } +
+ Показано {filtered.length} из {employees.length} + Сессии и пароли управляются в карточке сотрудника +
+
+ + ); +} + +function employeeStatusKey(employee: Employee): "active" | "invited" | "blocked" { + if (employee.isBlocked) return "blocked"; + if (employee.mustChangePassword) return "invited"; + return "active"; +} + +function EmployeeRow({ employee, block, menuId, setMenuId }: { employee: Employee; block: (employee: Employee) => void; menuId: number | null; setMenuId: (id: number | null) => void }) { + const dialogs = "0"; + const status = employeeStatusKey(employee); + const menuOpen = menuId === employee.id; + return ( + +
{employee.fullName || employee.email}{employee.email}
+ + {employee.department === "sales" ? "Продажи" : "—"} + + {employee.role === "OWNER" ? "сейчас · онлайн" : "не входил"} + {dialogs} + + + {menuOpen && ( +
+ event.preventDefault()}>Открыть карточку + event.preventDefault()}>Завершить сессии + event.preventDefault()}>Сбросить пароль + + +
+ )} + + + ); +} + +function RoleBadge({ role }: { role: Role }) { + return {role}; +} + +type ProductPeriod = "today" | "d7" | "d30"; + +function ProductsPage({ products, reload }: { products: Product[]; reload: () => void }) { + const [period, setPeriod] = useState("d30"); + const [menuId, setMenuId] = useState(null); + const periodLabel = { today: "Сегодня", d7: "7 дней", d30: "30 дней" }[period]; + async function deactivate(product: Product) { + if (product.status === "DISABLED") return; + await api(`/api/v1/company/products/${product.id}/deactivate/`, { method: "POST" }); + setMenuId(null); + reload(); + } + return ( + <> + {menuId !== null && + + )} + /> +
+
+ + + {products.map((product) => )} +
ПРОДУКТСТАТУСOFFER И ЦЕНЫSALES-AGENTКАНАЛЫFULFILLMENTПРОДАЖИ · {periodLabel.toUpperCase()}
+
+
+ {products.length} продукта + Активная цена не редактируется задним числом — создаётся новая версия +
+
+ + ); +} + +function ProductRow({ product, period, menuId, setMenuId, deactivate }: { product: Product; period: ProductPeriod; menuId: number | null; setMenuId: (id: number | null) => void; deactivate: (product: Product) => void }) { + const accent = productAccent(product.code); + const details = productDetails(product); + const sales = details.sales[period]; + const menuOpen = menuId === product.id; + return ( + +
{product.name}{details.sub}
+ + +
+ {details.offers.map((offer) =>
{offer.name} · {offer.type}{offer.price}
)} + {details.note &&

{details.note}

} +
+ +
AI-агент{details.agentRelease}
+
{details.channels.map((channel) => {channel.label})}
+
Подключён{details.fulfillment}
+
{sales.sum}{sales.n} продаж
+ + + {menuOpen && ( +
+ event.preventDefault()}>Открыть продукт + event.preventDefault()}>Добавить Offer + + +
+ )} + + + ); +} + +function productDetails(product: Product) { + const base = { + sub: product.siteUrl || product.code, + offers: [{ name: "—", type: "—", price: "—" }], + note: "", + agentRelease: "release —", + channels: [] as Array<{ label: string; color: string; bg: string }>, + fulfillment: "—", + sales: { + today: { n: 0, sum: "₽0" }, + d7: { n: 0, sum: "₽0" }, + d30: { n: 0, sum: "₽0" }, + }, + }; + const channels = { + max: { label: "MAX", color: "#6b5be0", bg: "#f2f0ff" }, + tg: { label: "TG", color: "#2f8fd0", bg: "#eaf6fd" }, + web: { label: "Web", color: "#0f9b8e", bg: "#e8f7f4" }, + }; + if (product.code === "firepage") { + return { + sub: "Нишевые сайты · коробка", + offers: [ + { name: "Коробка", type: "разовая", price: "₽4 900" }, + { name: "Годовая поддержка", type: "продление", price: "₽1 470 / год" }, + ], + note: "Поддержка — 30% от цены коробки", + agentRelease: "release v4 · published", + channels: [channels.max, channels.tg, channels.web], + fulfillment: "Сборка и выдача сайта", + sales: { + today: { n: 2, sum: "₽9 800" }, + d7: { n: 14, sum: "₽71 540" }, + d30: { n: 56, sum: "₽288 100" }, + }, + }; + } + if (product.code === "foxray") { + return { + sub: "SaaS · подписка", + offers: [ + { name: "Pro", type: "подписка", price: "₽4 900 / мес" }, + { name: "Max", type: "подписка", price: "₽9 900 / мес" }, + ], + note: "Скидка 20% при оплате за год", + agentRelease: "release v3 · published", + channels: [channels.max, channels.tg, channels.web], + fulfillment: "Выдача доступа", + sales: { + today: { n: 3, sum: "₽16 400" }, + d7: { n: 19, sum: "₽104 200" }, + d30: { n: 82, sum: "₽548 700" }, + }, + }; + } + return base; +} + +function ProductTag({ product }: { product: Product }) { + const accent = productAccent(product.code); + return {product.name}; +} + +function ProfilePage({ user, onLogout }: { user: SessionUser; onLogout: () => void }) { + return ( +
+
+ +
+

{user.fullName || user.email}

+

{user.email}

+
+ +
+
+

Личные данные

+
+ + + +
+
+
+
+

Смена пароля

+
+ + + +
+
+
+
+
+

Двухфакторная аутентификация (TOTP)

+

{user.totpEnabled ? "Включена. Для OWNER рекомендуется держать включённой." : "Отключена. Для роли OWNER настоятельно рекомендуется включить."}

+
+ + {user.totpEnabled &&
Приложение-аутентификатор подключено · последний код принят 5 мин назад
} +
+
+

Активные сессии

+
Браузерсейчас активнатекущая
+
+
+ ); +} + +function ReadOnlyField({ label, value, editable = false, mono = false }: { label: string; value: string; editable?: boolean; mono?: boolean }) { + return ; +} + +function PasswordField({ label, value = "", placeholder = "" }: { label: string; value?: string; placeholder?: string }) { + return ; +} + +function Segmented({ value, setValue, items }: { value: T; setValue: (value: T) => void; items: Array<[T, string]> }) { + return
{items.map(([key, label]) => )}
; +} + +function EmptyState({ title }: { title: string }) { + return
{title}
; +} + +function LoadingScreen() { + return
Загрузка
; +} + +function ErrorScreen({ retry }: { retry: () => void }) { + return
Ошибка загрузки
; +} export function App() { + const [sessionLoading, setSessionLoading] = useState(true); + const [user, setUser] = useState(null); + const [totpChallenge, setTotpChallenge] = useState(null); + const [route, setRoute] = useState("command"); + const [data, setData] = useState({ employees: [], departments: [], products: [] }); + const [dataError, setDataError] = useState(false); + + const loadData = useMemo(() => async () => { + setDataError(false); + try { + const [employees, departments, products] = await Promise.all([ + api<{ items: Employee[] }>("/api/v1/employees/"), + api<{ items: Department[] }>("/api/v1/company/departments/"), + api<{ items: Product[] }>("/api/v1/company/products/"), + ]); + setData({ employees: employees.items, departments: departments.items, products: products.items }); + } catch { + setDataError(true); + } + }, []); + + useEffect(() => { + api<{ authenticated: boolean; user?: SessionUser }>("/api/v1/auth/session/") + .then((payload) => { + if (payload.authenticated && payload.user) { + setUser(payload.user); + } + }) + .finally(() => setSessionLoading(false)); + }, []); + + useEffect(() => { + if (user) void loadData(); + }, [loadData, user]); + + async function logout() { + await api("/api/v1/auth/logout/", { method: "POST" }).catch(() => undefined); + setUser(null); + setTotpChallenge(null); + setRoute("command"); + setData({ employees: [], departments: [], products: [] }); + } + + if (sessionLoading) return ; + return ( - - -
Edevs Hub
- , label: "Командный центр" }, - { type: "group", label: "Компания" }, - { key: "departments", icon: , label: "Отделы" }, - { key: "products", icon: , label: "Продукты" }, - { type: "group", label: "Платформа" }, - { key: "ai", icon: , label: "AI" }, - ]} - /> - - -
- Edevs / Командный центр - Локальный контур -
- -
- Командный центр - - Первый рабочий каркас внутреннего Hub. Реальные домены подключаются на следующих этапах. - -
-
-
-
- Продажи - NORMAL -
-

Данные отдела появятся после реализации доменных модулей продаж, коммуникаций и коммерческого ядра.

-
-
-
-
- + {totpChallenge ? ( + { setTotpChallenge(null); setUser(nextUser); }} /> + ) : !user ? ( + { setUser(nextUser); void loadData(); }} onTotpChallenge={setTotpChallenge} /> + ) : user.mustChangePassword ? ( + + ) : user.totpRequired && !user.totpEnabled ? ( + + ) : dataError ? ( + + ) : ( + + )} ); } diff --git a/apps/internal-ui/src/styles.css b/apps/internal-ui/src/styles.css index 2e9717e..ede88ff 100644 --- a/apps/internal-ui/src/styles.css +++ b/apps/internal-ui/src/styles.css @@ -1,3 +1,7 @@ +* { + box-sizing: border-box; +} + html, body, #root { @@ -6,47 +10,2409 @@ body, margin: 0; } -.hub-shell { +body { + background: #f0f2f5; + color: #262626; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + -webkit-font-smoothing: antialiased; +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +button:focus { + outline: none; +} + +button:focus-visible { + outline: 2px solid #1677ff; + outline-offset: 2px; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-thumb { + background: #d9d9d9; + border: 2px solid #f0f2f5; + border-radius: 8px; +} + +::-webkit-scrollbar-thumb:hover { + background: #bfbfbf; +} + +.auth-screen { min-height: 100vh; -} - -.hub-sidebar { - border-right: 1px solid #e5e7eb; -} - -.hub-logo { - height: 56px; - padding: 18px 24px; - font-weight: 700; -} - -.hub-header { display: flex; align-items: center; - justify-content: space-between; - height: 56px; - padding: 0 24px; - border-bottom: 1px solid #e5e7eb; -} - -.hub-content { + justify-content: center; padding: 24px; + background: #f0f2f5; } -.page-header { +.auth-box { + width: 400px; + max-width: 100%; +} + +.auth-brand { + display: flex; + flex-direction: column; + align-items: center; margin-bottom: 24px; } -.department-grid { +.auth-logo { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 14px; + color: #ffffff; + background: #1677ff; + border-radius: 13px; + box-shadow: 0 4px 12px rgba(22, 119, 255, 0.3); +} + +.auth-logo svg { + width: 26px; + height: 26px; +} + +.auth-brand h1 { + margin: 0; + color: #1f1f1f; + font-size: 20px; + font-weight: 700; + letter-spacing: 0; +} + +.auth-brand p, +.auth-note { + margin: 6px 0 0; + color: #8c8c8c; + font-size: 12.5px; +} + +.auth-note { + text-align: center; +} + +.auth-card { + padding: 26px 26px 24px; + background: #ffffff; + border: 1px solid #f0f0f0; + border-radius: 14px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); +} + +.auth-error { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 11px 13px; + margin-bottom: 18px; + color: #cf1322; + background: #fff2f0; + border: 1px solid #ffccc7; + border-radius: 9px; + font-size: 12.5px; + line-height: 1.45; +} + +.auth-error-dot { + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + border-radius: 50%; + color: #ffffff; + background: #ff4d4f; + font-size: 12px; + font-weight: 700; +} + +.field-label { + display: block; + margin-bottom: 7px; + color: #595959; + font-size: 12.5px; + font-weight: 500; +} + +.auth-field { + height: 42px; + display: flex; + align-items: center; + gap: 9px; + padding: 0 13px; + margin-bottom: 16px; + color: #bfbfbf; + background: #ffffff; + border: 1px solid #e8e8e8; + border-radius: 9px; +} + +.auth-field.is-error { + border-color: #ffccc7; +} + +.auth-field input { + width: 100%; + border: none; + outline: none; + background: transparent; + color: #262626; + font-size: 14px; +} + +.auth-field button { + display: flex; + flex: none; + padding: 0; + border: none; + background: transparent; + color: #bfbfbf; +} + +.password-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 7px; +} + +.password-row .field-label { + margin: 0; +} + +.password-row a { + color: #1677ff; + font-size: 12px; + font-weight: 500; + text-decoration: none; +} + +.auth-password { + margin-bottom: 22px; +} + +.primary-button { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 16px; + color: #ffffff; + background: #1677ff; + border: none; + border-radius: 9px; + box-shadow: 0 1px 2px rgba(22, 119, 255, 0.3); + font-size: 13.5px; + font-weight: 600; + text-decoration: none; +} + +.primary-button:hover { + background: #0958d9; +} + +.auth-submit { + width: 100%; + height: 44px; + font-size: 14.5px; +} + +.password-strength { + margin: -6px 0 16px; +} + +.password-strength div { display: grid; - grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + grid-template-columns: repeat(4, 1fr); + gap: 6px; + margin-bottom: 7px; +} + +.password-strength span { + height: 4px; + background: #f0f0f0; + border-radius: 4px; +} + +.password-strength.score-1 span.active { + background: #ff4d4f; +} + +.password-strength.score-2 span.active { + background: #faad14; +} + +.password-strength.score-3 span.active, +.password-strength.score-4 span.active { + background: #52c41a; +} + +.password-strength p, +.auth-inline-error, +.auth-hint { + margin: 0; + color: #8c8c8c; + font-size: 12.5px; + line-height: 1.45; +} + +.auth-inline-error { + margin-top: -9px; + margin-bottom: 13px; + color: #cf1322; +} + +.password-requirements { + display: flex; + flex-direction: column; + gap: 8px; + margin: 2px 0 20px; + padding: 13px 14px; + background: #fafafa; + border: 1px solid #f0f0f0; + border-radius: 10px; +} + +.password-requirements strong { + color: #8c8c8c; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.password-requirements span { + color: #595959; + font-size: 12.5px; +} + +.password-requirements span.done { + color: #389e0d; +} + +.auth-account-strip { + display: flex; + align-items: center; + gap: 11px; + margin-bottom: 20px; + padding: 11px 13px; + background: #fafafa; + border: 1px solid #f0f0f0; + border-radius: 10px; +} + +.auth-account-strip span { + width: 34px; + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + color: #1677ff; + background: #e6f4ff; + border-radius: 50%; + font-size: 12px; + font-weight: 700; +} + +.auth-account-strip p { + margin: 0; + color: #8c8c8c; + font-size: 12.5px; +} + +.auth-account-strip strong { + color: #262626; + font-weight: 500; +} + +.auth-secret { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 18px; + padding: 11px 13px; + background: #fffbe6; + border: 1px solid #ffe58f; + border-radius: 10px; +} + +.auth-secret strong { + color: #8c8c8c; + font-size: 11px; + letter-spacing: 0.04em; +} + +.auth-secret code { + color: #262626; + font-family: "SF Mono", ui-monospace, Menlo, monospace; + font-size: 13px; + word-break: break-all; +} + +.auth-code-cells { + display: grid; + grid-template-columns: repeat(6, 48px); + justify-content: center; + gap: 8px; + margin-bottom: 14px; +} + +.auth-code-cells span { + width: 48px; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + color: #262626; + background: #ffffff; + border: 1px solid #d9d9d9; + border-radius: 10px; + font-size: 24px; + font-weight: 600; +} + +.auth-code-cells span.filled { + border-color: #1677ff; + box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1); +} + +.auth-code-input { + width: 100%; + height: 42px; + margin-bottom: 18px; + padding: 0 13px; + color: #262626; + background: #ffffff; + border: 1px solid #e8e8e8; + border-radius: 9px; + outline: none; + text-align: center; + font-size: 14px; +} + +.auth-code-input.spaced { + letter-spacing: 3px; +} + +.auth-hint { + margin-top: 14px; + text-align: center; +} + +.auth-link-button { + display: block; + margin: 10px auto 0; + padding: 0; + color: #1677ff; + background: transparent; + border: none; + font-size: 12.5px; + font-weight: 500; +} + +.hub-shell { + height: 100vh; + display: flex; + overflow: hidden; + background: #f0f2f5; +} + +.hub-sidebar { + width: 248px; + height: 100vh; + display: flex; + flex: none; + flex-direction: column; + background: #ffffff; + border-right: 1px solid #f0f0f0; +} + +.hub-brand { + height: 56px; + display: flex; + align-items: center; + flex: none; + gap: 11px; + padding: 0 20px; + border-bottom: 1px solid #f5f5f5; +} + +.hub-brand-mark { + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + flex: none; + color: #ffffff; + background: #1677ff; + border-radius: 8px; +} + +.hub-brand strong, +.profile-link strong { + display: block; + color: #1f1f1f; + font-size: 14px; + font-weight: 600; + line-height: 1.15; +} + +.hub-brand span, +.profile-link small { + display: block; + margin-top: 2px; + color: #8c8c8c; + font-size: 11px; + line-height: 1.15; +} + +.hub-nav { + flex: 1; + overflow-y: auto; + padding: 12px 12px 8px; +} + +.hub-nav-item, +.profile-link { + position: relative; + width: 100%; + display: flex; + align-items: center; + gap: 11px; + min-height: 36px; + padding: 9px 12px; + margin-bottom: 2px; + color: #595959; + background: transparent; + border: none; + border-radius: 8px; + font-size: 13.5px; + font-weight: 500; + text-align: left; + text-decoration: none; +} + +.hub-nav-item:not(:disabled):hover, +.profile-link:hover { + color: #262626; + background: #f5f5f5; +} + +.hub-nav-item.is-active, +.profile-link.is-active { + color: #0958d9; + background: #e6f4ff; + font-weight: 600; +} + +.active-bar { + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 3px; + border-radius: 0 3px 3px 0; + background: #1677ff; +} + +.hub-nav-group { + padding: 14px 12px 6px; + color: #bfbfbf; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; +} + +.hub-nav-divider { + height: 1px; + margin: 14px 12px 10px; + background: #f5f5f5; +} + +.profile-link { + margin: 0; + padding: 8px 10px; + border-top: 1px solid #f5f5f5; + border-radius: 0; +} + +.profile-link > span:last-child { + min-width: 0; +} + +.avatar { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + color: #ffffff; + background: #1677ff; + border-radius: 50%; + font-size: 13px; + font-weight: 600; +} + +.hub-main { + min-width: 0; + height: 100vh; + display: flex; + flex: 1; + flex-direction: column; +} + +.hub-topbar { + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + flex: none; + padding: 0 24px; + background: #ffffff; + border-bottom: 1px solid #f0f0f0; +} + +.breadcrumbs { + display: flex; + align-items: center; + gap: 8px; + color: #8c8c8c; + font-size: 13px; +} + +.breadcrumbs span { + color: #595959; + font-weight: 500; +} + +.breadcrumbs i { + color: #d9d9d9; + font-style: normal; +} + +.breadcrumbs strong { + color: #262626; + font-weight: 600; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 14px; +} + +.topbar-status { + height: 24px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 10px; + border: 1px solid; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + line-height: 22px; +} + +.topbar-status span { + width: 7px; + height: 7px; + border-radius: 50%; +} + +.contour { + display: flex; + align-items: center; + gap: 7px; + padding: 5px 11px; + color: #389e0d; + background: #f6ffed; + border: 1px solid #b7eb8f; + border-radius: 7px; + font-size: 12.5px; + font-weight: 600; +} + +.contour span { + width: 7px; + height: 7px; + border-radius: 50%; + background: #52c41a; +} + +.icon-button, +.user-button { + position: relative; + display: flex; + align-items: center; + justify-content: center; + color: #595959; + background: #ffffff; + border: 1px solid #f0f0f0; + border-radius: 8px; +} + +.icon-button { + width: 36px; + height: 36px; +} + +.icon-button b { + position: absolute; + top: 6px; + right: 7px; + min-width: 15px; + height: 15px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + color: #ffffff; + background: #ff4d4f; + border: 1.5px solid #ffffff; + border-radius: 8px; + font-size: 10px; + font-weight: 600; +} + +.icon-button b.is-dot { + min-width: 8px; + width: 8px; + height: 8px; + padding: 0; + border-radius: 50%; + font-size: 0; +} + +.topbar-divider { + width: 1px; + height: 24px; + background: #f0f0f0; +} + +.user-button { + gap: 9px; + padding: 4px 8px 4px 4px; + color: #8c8c8c; + background: transparent; + border-color: transparent; + cursor: pointer; +} + +.user-button:hover { + background: #f5f5f5; +} + +.topbar-avatar { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + color: #ffffff; + background: #1677ff; + border-radius: 50%; + font-size: 12px; + font-weight: 600; +} + +.hub-scroll { + flex: 1; + overflow-y: auto; + padding: 24px 28px 36px; +} + +.hub-page { + width: min(1200px, 100%); + min-width: 0; + max-width: 1200px; + margin: 0 auto; +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 20px; +} + +.page-header h1 { + margin: 0; + color: #1f1f1f; + font-size: 24px; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; +} + +.page-header p { + margin: 6px 0 0; + color: #8c8c8c; + font-size: 13.5px; +} + +.page-header p b { + color: #595959; + font-weight: 700; +} + +.command-page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 20px; +} + +.command-page-header h1 { + margin: 0; + color: #1f1f1f; + font-size: 26px; + font-weight: 700; + line-height: 1.2; +} + +.command-page-header p { + margin: 7px 0 0; + color: #8c8c8c; + font-size: 13px; +} + +.command-header-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.refresh-button { + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 14px; + color: #595959; + background: #ffffff; + border: 1px solid #d9d9d9; + border-radius: 7px; + font-size: 13px; + font-weight: 600; +} + +.company-status-banner { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + padding: 16px 18px 16px 16px; + background: #ffffff; + border: 1px solid #f0f0f0; + border-left: 4px solid #52c41a; + border-radius: 10px; +} + +.company-status-dot { + width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + border-radius: 50%; +} + +.company-status-dot span { + width: 11px; + height: 11px; + border-radius: 50%; +} + +.company-status-text { + min-width: 0; + flex: 1; +} + +.company-status-text strong { + display: block; + color: #1f1f1f; + font-size: 16px; + font-weight: 700; +} + +.company-status-text p { + margin: 5px 0 0; + color: #8c8c8c; + font-size: 13px; +} + +.company-status-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(112px, 1fr)); + gap: 12px; + flex: 0 0 412px; +} + +.company-status-metrics div { + min-height: 52px; + padding: 9px 11px; + background: #fafafa; + border: 1px solid #f5f5f5; + border-radius: 8px; +} + +.company-status-metrics span { + display: block; + color: #8c8c8c; + font-size: 11px; + line-height: 1.2; +} + +.company-status-metrics strong { + display: block; + margin-top: 6px; + color: #262626; + font-size: 18px; + font-weight: 700; + line-height: 1; +} + +.company-status-metrics strong.success { + color: #389e0d; +} + +.command-two-column { + display: flex; + align-items: flex-start; + gap: 20px; +} + +.command-left { + min-width: 0; + flex: 1; +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.section-head h2 { + margin: 0; + color: #1f1f1f; + font-size: 18px; + font-weight: 700; +} + +.section-head span { + color: #8c8c8c; + font-size: 13px; +} + +.sales-card { + padding: 20px; + background: #ffffff; + border: 1px solid #f0f0f0; + border-radius: 10px; +} + +.sales-head { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.sales-icon { + width: 44px; + height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + color: #1677ff; + background: #e6f4ff; + border-radius: 10px; +} + +.sales-title { + min-width: 0; + flex: 1; +} + +.sales-title div { + display: flex; + align-items: center; + gap: 9px; +} + +.sales-title h3 { + margin: 0; + color: #1f1f1f; + font-size: 20px; + font-weight: 700; +} + +.sales-title p { + margin: 7px 0 0; + color: #8c8c8c; + font-size: 13px; +} + +.command-status-label { + height: 24px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 9px; + border: 1px solid; + border-radius: 999px; + font-size: 12px; + font-weight: 600; +} + +.command-status-label span { + width: 7px; + height: 7px; + border-radius: 50%; +} + +.command-status-label em { + font-style: normal; +} + +.sales-open { + height: 36px; + flex: none; + gap: 7px; + padding: 0 13px; + border-radius: 7px; + font-size: 13px; +} + +.dept-summary { + margin-top: 16px; + padding: 13px 14px; + color: #595959; + background: #fafafa; + border: 1px solid #f5f5f5; + border-radius: 8px; + font-size: 13px; + line-height: 1.45; +} + +.metric-group-title { + margin: 18px 0 9px; + color: #8c8c8c; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.metric-group-grid { + display: grid; + gap: 10px; +} + +.metric-cell { + min-height: 74px; + padding: 12px; + background: #ffffff; + border: 1px solid #f0f0f0; + border-radius: 9px; +} + +.metric-cell div { + display: flex; + align-items: center; + gap: 6px; + min-height: 28px; + color: #8c8c8c; + font-size: 12px; + line-height: 1.25; +} + +.metric-cell div span { + width: 7px; + height: 7px; + flex: none; + border-radius: 50%; +} + +.metric-cell strong { + display: block; + margin-top: 8px; + color: #262626; + font-size: 22px; + font-weight: 700; + line-height: 1; +} + +.command-rail { + width: 344px; + display: flex; + flex: none; + flex-direction: column; gap: 16px; } -.department-card { - min-height: 180px; - padding: 20px; +.rail-card, +.ai-spend-card { + padding: 16px; background: #ffffff; - border: 1px solid #e5e7eb; + border: 1px solid #f0f0f0; + border-radius: 10px; +} + +.rail-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 13px; +} + +.rail-card-head > div { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.rail-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; +} + +.rail-card-head strong { + color: #1f1f1f; + font-size: 15px; + font-weight: 700; +} + +.rail-card-head b { + min-width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 6px; + color: #faad14; + background: #fffbe6; + border: 1px solid #ffe58f; + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.rail-card-head a, +.rail-card-head > span { + flex: none; + color: #1677ff; + font-size: 12px; + font-weight: 600; + text-decoration: none; +} + +.rail-card-body { + display: flex; + flex-direction: column; + gap: 9px; +} + +.attention-row, +.integration-row { + display: grid; + align-items: start; + gap: 9px; + padding: 10px; + background: #fafafa; + border: 1px solid #f5f5f5; + border-radius: 8px; + text-decoration: none; +} + +.attention-row { + grid-template-columns: 8px minmax(0, 1fr) auto; +} + +.integration-row { + grid-template-columns: 8px minmax(0, 1fr) auto; +} + +.attention-row > span:first-child, +.integration-row > span:first-child { + width: 8px; + height: 8px; + margin-top: 5px; + border-radius: 50%; +} + +.attention-row strong, +.integration-row strong { + display: block; + color: #1f1f1f; + font-size: 13px; + font-weight: 600; + line-height: 1.35; +} + +.attention-row small, +.integration-row small { + display: block; + margin-top: 3px; + color: #8c8c8c; + font-size: 12px; + line-height: 1.25; +} + +.attention-row em, +.integration-row em { + color: #8c8c8c; + font-size: 12px; + font-style: normal; + line-height: 1.35; + white-space: nowrap; +} + +.ai-spend-main { + display: flex; + align-items: baseline; + gap: 6px; + margin-bottom: 10px; +} + +.ai-spend-main strong { + color: #1f1f1f; + font-size: 22px; + font-weight: 700; +} + +.ai-spend-main span { + color: #8c8c8c; + font-size: 12px; +} + +.ai-progress { + height: 8px; + overflow: hidden; + background: #f5f5f5; + border-radius: 999px; +} + +.ai-progress span { + height: 100%; + display: block; + border-radius: inherit; +} + +.ai-spend-card p { + margin: 8px 0 12px; + color: #8c8c8c; + font-size: 12px; +} + +.ai-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.ai-grid div { + padding: 9px 8px; + background: #fafafa; + border: 1px solid #f5f5f5; border-radius: 8px; } + +.ai-grid span { + display: block; + color: #8c8c8c; + font-size: 11px; +} + +.ai-grid strong { + display: block; + margin-top: 6px; + color: #262626; + font-size: 14px; + font-weight: 700; +} + +.command-updated { + margin-top: 14px; + color: #bfbfbf; + font-size: 12px; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + margin-bottom: 16px; +} + +.metric-card, +.department-card, +.department-large-card, +.table-card, +.profile-card, +.profile-header-card, +.sessions-card, +.state-card { + background: #ffffff; + border: 1px solid #f0f0f0; + border-radius: 12px; +} + +.metric-card { + min-height: 102px; + padding: 18px 20px; +} + +.metric-card.is-compact { + min-height: auto; + padding: 14px 16px; + border-radius: 0; + border-top: none; + border-bottom: none; + border-left: none; +} + +.metric-card span, +.metric-card small { + display: block; + color: #8c8c8c; + font-size: 12.5px; +} + +.metric-card strong { + display: block; + margin-top: 7px; + color: #262626; + font-size: 22px; + font-weight: 700; +} + +.metric-card small { + margin-top: 4px; +} + +.departments-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(380px, 460px)); + gap: 18px; +} + +.department-card { + overflow: hidden; + border-radius: 14px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.department-card-header { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 20px 22px 16px; +} + +.dept-icon { + width: 48px; + height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + color: #1677ff; + background: #e6f4ff; + border-radius: 12px; +} + +.department-card-title { + min-width: 0; + flex: 1; +} + +.department-card-title > div { + display: flex; + align-items: center; + gap: 10px; +} + +.department-card h2 { + margin: 0; + color: #1f1f1f; + font-size: 19px; + font-weight: 700; +} + +.department-card .command-status-label { + height: auto; + padding: 3px 10px; + border-radius: 16px; + font-size: 11.5px; +} + +.department-card .command-status-label span { + width: 6px; + height: 6px; +} + +.department-card-title p { + margin: 5px 0 0; + color: #8c8c8c; + font-size: 12.5px; + line-height: 1.45; +} + +.department-meta { + display: flex; + flex-direction: column; + gap: 13px; + padding: 0 22px 16px; +} + +.department-meta div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.department-meta span { + color: #8c8c8c; + font-size: 12.5px; +} + +.department-meta strong { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + color: #262626; + font-size: 13px; + font-weight: 500; +} + +.owner-person i { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + color: #ffffff; + background: #9254de; + border-radius: 50%; + font-size: 10.5px; + font-style: normal; + font-weight: 600; +} + +.department-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-top: 1px solid #f5f5f5; +} + +.department-stats div { + min-height: 76px; + padding: 14px 16px; + border-right: 1px solid #f5f5f5; +} + +.department-stats div:last-child { + border-right: 0; +} + +.department-stats span { + display: block; + color: #8c8c8c; + font-size: 11.5px; +} + +.department-stats strong { + display: block; + margin-top: 5px; + color: #262626; + font-size: 19px; + font-weight: 700; +} + +.department-stats strong.success { + color: #389e0d; +} + +.department-action { + padding: 14px 22px 18px; + border-top: 1px solid #f5f5f5; +} + +.department-action a { + height: 40px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: #ffffff; + background: #1677ff; + border-radius: 9px; + box-shadow: 0 1px 2px rgba(22, 119, 255, 0.3); + font-size: 13.5px; + font-weight: 600; + text-decoration: none; +} + +.department-action a:hover { + background: #0958d9; +} + +.product-icon { + width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + color: #1677ff; + background: #e6f4ff; + border-radius: 9px; +} + +.detail-list { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px 20px; + margin-top: 18px; +} + +.detail-list div { + display: flex; + justify-content: space-between; + gap: 14px; + padding-bottom: 10px; + border-bottom: 1px solid #f5f5f5; +} + +.detail-list span, +.muted-note, +.table-footer { + color: #8c8c8c; + font-size: 12.5px; +} + +.detail-list strong { + color: #262626; + font-size: 12.5px; + font-weight: 600; +} + +.product-tags { + display: flex; + gap: 6px; +} + +.product-tags span { + padding: 2px 9px; + border-radius: 6px; + font-size: 11.5px; + font-weight: 600; +} + +.muted-note { + margin: 18px 0 0; + color: #bfbfbf; +} + +.filter-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + margin-bottom: 14px; + background: #ffffff; + border: 1px solid #f0f0f0; + border-radius: 10px; +} + +.employees-filter { + justify-content: flex-start; + gap: 14px; + flex-wrap: wrap; + padding: 11px 14px; + margin-bottom: 16px; + border-radius: 11px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); +} + +.employee-search { + width: 260px; + height: 34px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 11px; + color: #bfbfbf; + background: #fafafa; + border: 1px solid #e8e8e8; + border-radius: 8px; +} + +.employee-search:focus-within { + border-color: #1677ff; +} + +.employee-search input { + width: 100%; + min-width: 0; + color: #262626; + background: transparent; + border: 0; + outline: none; + font: inherit; + font-size: 13px; +} + +.employee-search input::placeholder { + color: #bfbfbf; +} + +.filter-group { + display: flex; + align-items: center; + gap: 8px; +} + +.filter-group > span { + color: #8c8c8c; + font-size: 12px; + font-weight: 500; +} + +.reset-filter { + height: 34px; + margin-left: auto; + padding: 0 12px; + color: #8c8c8c; + background: transparent; + border: 0; + border-radius: 8px; + font-size: 13px; + font-weight: 500; +} + +.reset-filter:hover { + color: #1677ff; + background: #f5f5f5; +} + +.segmented { + display: flex; + align-items: center; + gap: 3px; + padding: 3px; + background: #f0f2f5; + border-radius: 8px; +} + +.segmented button { + height: 30px; + padding: 0 13px; + color: #595959; + background: transparent; + border: none; + border-radius: 6px; + font-size: 12.5px; + font-weight: 500; + white-space: nowrap; +} + +.segmented button.active { + color: #1f1f1f; + background: #ffffff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + font-weight: 600; +} + +.table-card { + overflow-x: auto; + border-radius: 10px; +} + +.employees-card { + position: relative; + overflow: visible; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.table-scroll { + overflow: visible; + border-radius: 12px 12px 0 0; +} + +.baseline-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.employees-table { + min-width: 1000px; +} + +.products-table { + min-width: 1080px; +} + +.products-header-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.products-card { + overflow: visible; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.product-table-scroll { + overflow: visible; + border-radius: 12px 12px 0 0; +} + +.products-table th:first-child, +.products-table td:first-child { + padding-left: 18px; +} + +.products-table td { + padding-top: 15px; + padding-bottom: 15px; + vertical-align: top; +} + +.products-table .product-cell { + gap: 12px; +} + +.products-table .product-icon { + width: 40px; + height: 40px; + border-radius: 10px; +} + +.products-table .product-cell strong { + color: #1677ff; + font-size: 14px; + font-weight: 700; +} + +.products-table .product-cell small { + color: #bfbfbf; + font-family: inherit; + font-size: 11.5px; +} + +.offer-list { + width: 260px; +} + +.offer-list div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 3px 0; +} + +.offer-list span { + color: #262626; + font-size: 12.5px; +} + +.offer-list em { + color: #bfbfbf; + font-size: 11px; + font-style: normal; +} + +.offer-list strong { + flex: none; + color: #262626; + font-size: 12.5px; + font-weight: 600; + white-space: nowrap; +} + +.offer-list p { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 4px 0 0; + color: #8c8c8c; + font-size: 11px; +} + +.offer-list p svg { + color: #389e0d; +} + +.agent-state span, +.fulfillment-state span { + display: inline-flex; + align-items: center; + gap: 6px; + color: #722ed1; + font-size: 12.5px; + font-weight: 500; +} + +.fulfillment-state span { + color: #389e0d; +} + +.agent-state i, +.fulfillment-state i { + width: 7px; + height: 7px; + display: inline-block; + background: #722ed1; + border-radius: 50%; +} + +.fulfillment-state i { + background: #52c41a; +} + +.agent-state small, +.fulfillment-state small { + display: block; + margin-top: 2px; + color: #bfbfbf; + font-size: 11px; +} + +.channel-tags { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.channel-tags span { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 7px; + border-radius: 5px; + font-size: 10.5px; + font-weight: 600; +} + +.channel-tags i { + width: 5px; + height: 5px; + display: inline-block; + border-radius: 50%; +} + +.product-sales strong { + display: block; + color: #1f1f1f; + font-size: 15px; + font-weight: 700; +} + +.product-sales small { + display: block; + margin-top: 2px; + color: #8c8c8c; + font-size: 11.5px; +} + +.products-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 12px 18px; + color: #8c8c8c; + border-top: 1px solid #f5f5f5; + font-size: 12.5px; +} + +.products-footer span:last-child { + color: #bfbfbf; +} + +.baseline-table th { + padding: 11px 12px; + color: #8c8c8c; + background: #fafafa; + border-bottom: 1px solid #f0f0f0; + font-size: 11px; + font-weight: 600; + text-align: left; +} + +.baseline-table thead th:first-child { + border-top-left-radius: 12px; +} + +.baseline-table thead th:last-child { + border-top-right-radius: 12px; +} + +.baseline-table th:first-child, +.baseline-table td:first-child { + padding-left: 16px; +} + +.baseline-table td { + padding: 12px; + border-bottom: 1px solid #f5f5f5; + color: #262626; + vertical-align: middle; +} + +.baseline-table tbody tr:hover { + background: #fafbfc; +} + +.person-cell, +.product-cell { + display: flex; + align-items: center; + gap: 11px; +} + +.person-cell strong { + display: block; + color: #1677ff; + font-size: 13.5px; + font-weight: 600; +} + +.product-cell strong { + display: block; + color: #262626; + font-size: 13px; + font-weight: 600; +} + +.person-cell small, +.product-cell small { + display: block; + margin-top: 3px; + color: #bfbfbf; + font-family: "SF Mono", ui-monospace, Menlo, monospace; + font-size: 11.5px; +} + +.role-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 6px; + font-size: 11.5px; + font-weight: 600; +} + +.role-badge.owner { + color: #595959; + background: #f0f0f0; +} + +.role-badge.operator { + color: #0958d9; + background: #e6f4ff; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border: 1px solid transparent; + border-radius: 6px; + font-size: 12.5px; + font-weight: 500; +} + +.status-pill span { + width: 7px; + height: 7px; + border-radius: 50%; +} + +.row-actions { + position: relative; + text-align: center; +} + +.row-menu-button { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #8c8c8c; + background: transparent; + border: none; + border-radius: 7px; +} + +.row-menu-button:hover { + color: #262626; + background: #f0f0f0; +} + +.row-menu { + position: absolute; + top: 42px; + right: 12px; + z-index: 30; + width: 206px; + padding: 5px; + text-align: left; + background: #ffffff; + border: 1px solid #f0f0f0; + border-radius: 10px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); +} + +.row-menu a, +.row-menu button { + width: 100%; + min-height: 32px; + display: flex; + align-items: center; + gap: 9px; + padding: 8px 10px; + color: #262626; + background: transparent; + border: 0; + border-radius: 7px; + font-size: 13px; + font-weight: 500; + text-align: left; + text-decoration: none; +} + +.row-menu a:hover, +.row-menu button:hover { + background: #f5f5f5; +} + +.row-menu span { + display: block; + height: 1px; + margin: 4px 8px; + background: #f5f5f5; +} + +.row-menu button.danger { + color: #cf1322; +} + +.row-menu button.success { + color: #389e0d; +} + +.row-menu button.warning { + color: #d48806; +} + +.row-menu button:disabled { + color: #bfbfbf; + cursor: not-allowed; +} + +.row-menu button:disabled:hover { + background: transparent; +} + +.numeric { + text-align: right; +} + +.muted-number { + color: #bfbfbf !important; +} + +.table-footer { + margin-top: 10px; + color: #bfbfbf; +} + +.employees-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 12px 18px; + color: #8c8c8c; + border-top: 1px solid #f5f5f5; + font-size: 12.5px; +} + +.employees-footer span:last-child { + color: #bfbfbf; +} + +.menu-scrim { + position: fixed; + inset: 0; + z-index: 20; + background: transparent; + border: 0; +} + +.empty-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 160px; + color: #595959; +} + +.profile-stack { + display: flex; + flex-direction: column; + gap: 16px; +} + +.profile-header-card { + display: flex; + align-items: center; + gap: 16px; + padding: 20px 22px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.profile-header-card .avatar { + width: 60px; + height: 60px; + font-size: 21px; +} + +.profile-header-main { + flex: 1; + min-width: 0; +} + +.profile-header-main > div { + display: flex; + align-items: center; + gap: 11px; +} + +.profile-header-main h1 { + display: inline-block; + margin: 0; + color: #1f1f1f; + font-size: 21px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.profile-header-main p { + margin: 5px 0 0; + color: #8c8c8c; + font-family: "SF Mono", ui-monospace, Menlo, monospace; + font-size: 12.5px; +} + +.danger-outline { + height: 38px; + display: inline-flex; + align-items: center; + gap: 7px; + flex: none; + padding: 0 15px; + color: #cf1322; + background: #ffffff; + border: 1px solid #ffccc7; + border-radius: 9px; + font-size: 13px; + font-weight: 500; +} + +.danger-outline:hover { + background: #fff2f0; +} + +.profile-card { + padding: 18px 20px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.profile-card h3 { + margin: 0 0 16px; + color: #1f1f1f; + font-size: 14px; + font-weight: 700; +} + +.profile-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 14px; +} + +.readonly-field { + display: block; +} + +.readonly-field span { + display: block; + margin-bottom: 6px; + color: #8c8c8c; + font-size: 12px; + font-weight: 500; +} + +.readonly-field input { + width: 100%; + height: 38px; + padding: 0 12px; + color: #262626; + background: #ffffff; + border: 1px solid #e8e8e8; + border-radius: 8px; + outline: none; + font-size: 13px; +} + +.readonly-field input.mono { + font-family: "SF Mono", ui-monospace, Menlo, monospace; +} + +.readonly-field input:disabled { + color: #8c8c8c; + background: #fafafa; + border-color: #f0f0f0; +} + +.profile-actions { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} + +.secondary-button { + height: 38px; + padding: 0 16px; + color: #262626; + background: #ffffff; + border: 1px solid #d9d9d9; + border-radius: 9px; + font-size: 13px; + font-weight: 500; +} + +.secondary-button:hover { + color: #1677ff; + border-color: #1677ff; +} + +.password-fields { + max-width: 380px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.totp-card { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.totp-card > div:first-child { + flex: 1; + min-width: 0; +} + +.totp-card p { + margin: 4px 0 0; + color: #8c8c8c; + font-size: 12.5px; + line-height: 1.5; +} + +.totp-switch { + width: 46px; + height: 26px; + display: flex; + align-items: center; + justify-content: flex-start; + flex: none; + padding: 2px; + background: #d9d9d9; + border-radius: 14px; +} + +.totp-switch.on { + justify-content: flex-end; + background: #52c41a; +} + +.totp-switch i { + width: 22px; + height: 22px; + display: block; + background: #ffffff; + border-radius: 50%; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.security-note { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + margin-top: 14px; + padding: 11px 14px; + border-radius: 9px; + font-size: 12.5px; +} + +.security-note.ok { + color: #389e0d; + background: #f6ffed; + border: 1px solid #d9f7be; +} + +.security-note.warn { + color: #d48806; + background: #fffbe6; + border: 1px solid #ffe58f; +} + +.sessions-card { + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.sessions-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 20px 12px; +} + +.sessions-card h3 { + margin: 0; + color: #1f1f1f; + font-size: 14px; + font-weight: 700; +} + +.sessions-card button { + height: 32px; + padding: 0 12px; + color: #cf1322; + background: #ffffff; + border: 1px solid #ffccc7; + border-radius: 8px; + font-size: 12.5px; + font-weight: 500; +} + +.session-row { + display: flex; + align-items: center; + gap: 13px; + padding: 13px 20px; + border-top: 1px solid #f5f5f5; +} + +.session-icon { + width: 34px; + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + color: #8c8c8c; + background: #f5f5f5; + border-radius: 9px; +} + +.session-row strong { + display: block; + color: #262626; + font-size: 13px; + font-weight: 500; +} + +.session-row small { + display: block; + margin-top: 3px; + color: #8c8c8c; + font-size: 11.5px; +} + +.session-row b { + padding: 1px 8px; + color: #389e0d; + background: #f6ffed; + border-radius: 5px; + font-size: 10.5px; + font-weight: 600; +} + +.state-screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: #f0f2f5; +} + +.state-card { + min-width: 260px; + display: grid; + justify-items: center; + gap: 16px; + padding: 24px; + color: #595959; + font-size: 13px; +} diff --git a/design/internal/baseline/.thumbnail b/design/internal/baseline/.thumbnail index 1434a0b..7352e03 100644 Binary files a/design/internal/baseline/.thumbnail and b/design/internal/baseline/.thumbnail differ diff --git a/design/internal/baseline/AUTH · TOTP.dc.html b/design/internal/baseline/AUTH · TOTP.dc.html new file mode 100644 index 0000000..73489e4 --- /dev/null +++ b/design/internal/baseline/AUTH · TOTP.dc.html @@ -0,0 +1,96 @@ + + + + + + + + + + + + + +
+
+ +
+
+

Двухфакторная проверка

+

Введите 6-значный код из приложения-аутентификатора

+
+ +
+
+
ИП
+ Вход как ivan@edevs.tech · OWNER +
+ + +
+ +
{{ c.char }}
+
+
+ + +
Неверный код. Осталось попыток: 4
+
+ + + + + + +
+ + Код обновляется в приложении каждые 30 секунд +
+
+ + +
+
+
+ + + diff --git a/design/internal/baseline/AUTH · Код TOTP.dc.html b/design/internal/baseline/AUTH · Код TOTP.dc.html new file mode 100644 index 0000000..608df4c --- /dev/null +++ b/design/internal/baseline/AUTH · Код TOTP.dc.html @@ -0,0 +1,100 @@ + + + + + + + + + + + + + +
+
+ +
+
+

Подтверждение входа

+

Введите 6-значный код из приложения-аутентификатора для ivan@edevs.tech

+
+ +
+ + +
+ +
{{ c.val }}
+
+
+ + +
+ + Неверный код. Осталось попыток: 2 +
+
+ + + + + + +
+ + Код обновится через {{ countdown }} +
+
+ +

Нет доступа к коду? Связаться с поддержкой

+
+
+
+ + + diff --git a/design/internal/baseline/AUTH · Смена пароля.dc.html b/design/internal/baseline/AUTH · Смена пароля.dc.html new file mode 100644 index 0000000..78e63dc --- /dev/null +++ b/design/internal/baseline/AUTH · Смена пароля.dc.html @@ -0,0 +1,113 @@ + + + + + + + + + + + + + +
+
+ +
+
+

Смена временного пароля

+

Вы вошли по временному паролю. Задайте постоянный пароль, чтобы продолжить.

+
+ +
+ + +
+ + + +
+ + +
+
+
+
+
+
+
{{ strengthLabel }}
+ + +
+ + +
+
Пароли не совпадают
+ + +
+
ТРЕБОВАНИЯ К ПАРОЛЮ
+ +
{{ r.icon }}{{ r.label }}
+
+
+ + +
+
+
+
+ + + diff --git a/design/internal/baseline/uploads/SPEC-HUB-0003-Web-Chat.md b/design/internal/baseline/uploads/SPEC-HUB-0003-Web-Chat.md new file mode 100644 index 0000000..645200c --- /dev/null +++ b/design/internal/baseline/uploads/SPEC-HUB-0003-Web-Chat.md @@ -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 + +``` + +Точные имена 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. diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts index 53ef034..bf77625 100644 --- a/packages/ui/src/theme.ts +++ b/packages/ui/src/theme.ts @@ -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)", }, }, };