✨ feat(portals): complete custom domains and unified navigation

This commit is contained in:
Andrey committed 2026-07-29 16:49:50 +03:00
1 parent 2b246e193b
commit 3f07d76173
38 files changed
+1592 -596

No files matched your search

+12
View File
@@ -60,6 +60,18 @@ the local migration DB role, is excluded from the backend image, and the
importer refuses any environment other than `CUS_ENV=local`. Production compose
has no seed service or seed command.
Демо-контент портала FoxRay и отдельный анонимный Web-виджет поддержки можно
установить независимо от общего seed:
```powershell
.\scripts\seed-support-portal.ps1
```
Этот portal-only импорт идемпотентен и не создаёт пользователей, не меняет
пароли, роли, сессии или права доступа. Он публикует локальный портал
`http://foxray.localhost/`, категории и статьи, а также подключает существующий
Web Chat через отдельный канал `foxray-help`.
## Tests
All suites run in Docker, so no manual environment is required — the test
+22
View File
@@ -1,5 +1,6 @@
import os
import sys
from ipaddress import IPv4Address
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
@@ -215,6 +216,27 @@ CUS_HELP_BASE_DOMAIN = os.environ.get(
).strip().lower().rstrip(".")
CUS_HELP_PUBLIC_SCHEME = os.environ.get("CUS_HELP_PUBLIC_SCHEME", "https").strip().lower()
CUS_HELP_PUBLIC_PORT = os.environ.get("CUS_HELP_PUBLIC_PORT", "").strip()
_default_help_public_ipv4 = os.environ.get(
"CUSTOCRM_WEB_LISTENING_IP",
"",
).strip()
if _default_help_public_ipv4 in {"", "0.0.0.0", "::"}:
_default_help_public_ipv4 = "127.0.0.1" if CUS_HELP_BASE_DOMAIN == "localhost" else ""
CUS_HELP_PUBLIC_IPV4 = os.environ.get(
"CUS_HELP_PUBLIC_IPV4",
_default_help_public_ipv4,
).strip()
if not DEBUG and not TESTING and not CUS_HELP_PUBLIC_IPV4:
raise ImproperlyConfigured(
"CUS_HELP_PUBLIC_IPV4 or a non-wildcard CUSTOCRM_WEB_LISTENING_IP is required"
)
if CUS_HELP_PUBLIC_IPV4:
try:
IPv4Address(CUS_HELP_PUBLIC_IPV4)
except ValueError as error:
raise ImproperlyConfigured(
"CUS_HELP_PUBLIC_IPV4 must be a valid IPv4 address"
) from error
# P2P calls: opaque invitation lifetime and short-lived signaling/media access.
CUS_CALL_INVITE_TTL_SECONDS = int(os.environ.get("CUS_CALL_INVITE_TTL_SECONDS", str(5 * 60)))
@@ -4,6 +4,7 @@ import uuid
import dns.exception
import dns.resolver
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils import timezone
@@ -48,6 +49,27 @@ def verify_custom_domain(portal: SupportPortal) -> SupportPortal:
)
if not portal.custom_domain:
raise ValidationError({"customDomain": "Сначала укажите домен"})
if settings.CUS_HELP_PUBLIC_IPV4:
try:
address_answers = dns.resolver.resolve(portal.custom_domain, "A")
addresses = {
getattr(answer, "address", str(answer).rstrip("."))
for answer in address_answers
}
except (
dns.resolver.NoAnswer,
dns.resolver.NXDOMAIN,
dns.resolver.NoNameservers,
dns.exception.Timeout,
) as error:
raise ValidationError(
{"customDomain": "A-запись домена пока не найдена"}
) from error
if settings.CUS_HELP_PUBLIC_IPV4 not in addresses:
raise ValidationError(
{"customDomain": "A-запись домена указывает не на сервер CustoCRM"}
)
expected = domain_verification_value(portal)
try:
answers = dns.resolver.resolve(domain_verification_name(portal), "TXT")
@@ -0,0 +1,20 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [("support_portals", "0005_custom_domain_verification_token")]
operations = [
migrations.AddField(
model_name="supportportal",
name="widget_channel",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="support_portal_widgets",
to="channels.channel",
),
),
]
@@ -11,7 +11,7 @@ from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus
class SupportPortal(TenantRelationModel):
"""Публичный Help Center, управляемый внутри отдела поддержки."""
tenant_relation_fields = ("department",)
tenant_relation_fields = ("department", "widget_channel")
department = models.ForeignKey(
"identity.Department",
on_delete=models.PROTECT,
@@ -34,6 +34,13 @@ class SupportPortal(TenantRelationModel):
)
transition_version = models.PositiveIntegerField(default=0)
published_at = models.DateTimeField(null=True, blank=True)
widget_channel = models.ForeignKey(
"channels.Channel",
on_delete=models.PROTECT,
related_name="support_portal_widgets",
null=True,
blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
products = models.ManyToManyField(
@@ -69,6 +76,19 @@ class SupportPortal(TenantRelationModel):
raise ValidationError(
{"department": "Support portal must belong to the support department"}
)
if self.widget_channel_id is not None:
channel = self.widget_channel
if channel.department_id is None or channel.department.code != "support":
raise ValidationError(
{"widget_channel": "Portal widget must belong to the support department"}
)
if (
channel.requires_authenticated_product_identity
or not channel.allow_anonymous_sessions
):
raise ValidationError(
{"widget_channel": "Portal widget must allow anonymous web sessions"}
)
class SupportPortalProduct(TenantRelationModel):
@@ -6,6 +6,7 @@ from django.utils import timezone
from hub_platform.channels.models import Channel
from hub_platform.identity.models import Department
from hub_platform.integrations.models import IntegrationProvider, IntegrationStatus
from hub_platform.products.models import Product
from hub_platform.subscriptions.keys import EntitlementKey, QuotaKey
from hub_platform.subscriptions.policy import require_entitlement
@@ -24,6 +25,7 @@ class PortalInput:
slug: str
name: str
default_locale: str = "ru"
widget_channel_id: int | None = None
@transaction.atomic
@@ -40,6 +42,7 @@ def create_portal(*, context: TenantContext, data: PortalInput) -> SupportPortal
hosted_domain=hosted_domain(data.slug.strip().lower()),
name=data.name.strip(),
default_locale=data.default_locale.strip().lower() or "ru",
widget_channel=_widget_channel(context, data.widget_channel_id),
)
portal.full_clean()
portal.save()
@@ -65,11 +68,41 @@ def update_portal(
portal.hosted_domain = hosted_domain(portal.slug)
portal.name = data.name.strip()
portal.default_locale = data.default_locale.strip().lower() or "ru"
portal.widget_channel = _widget_channel(context, data.widget_channel_id)
portal.full_clean()
portal.save()
return portal
def _widget_channel(
context: TenantContext,
channel_id: int | None,
) -> Channel | None:
if channel_id is None:
return None
try:
return (
Channel.objects.select_related("department")
.filter(
id=channel_id,
organization=context.organization,
department__code="support",
is_active=True,
requires_authenticated_product_identity=False,
allow_anonymous_sessions=True,
connections__provider=IntegrationProvider.WEB,
connections__status=IntegrationStatus.OK,
connections__is_active=True,
)
.distinct()
.get()
)
except Channel.DoesNotExist as error:
raise ValidationError(
{"widgetChannelId": "Активный Web-виджет поддержки не найден"}
) from error
@transaction.atomic
def set_portal_status(
*, context: TenantContext, portal: SupportPortal, status: str
@@ -8,6 +8,7 @@ from hub_platform.api.permissions import HasCapability, HasEntitlement
from hub_platform.channels.models import Channel
from hub_platform.channels.serializers import channel_payload
from hub_platform.identity.audit import record_audit_event
from hub_platform.integrations.models import IntegrationProvider, IntegrationStatus
from hub_platform.support_portals.api import validation_response
from hub_platform.support_portals.models import SupportPortal
from hub_platform.support_portals.domain_services import (
@@ -28,6 +29,17 @@ from hub_platform.subscriptions.keys import QuotaKey
from hub_platform.subscriptions.policy import get_effective_policy
def _optional_id(value) -> int | None:
if value in (None, ""):
return None
try:
return int(value)
except (TypeError, ValueError) as error:
raise ValidationError(
{"widgetChannelId": "Канал веб-виджета не найден"}
) from error
class PortalBaseView(APIView):
permission_classes = [HasEntitlement, HasCapability]
required_entitlement = "support_department"
@@ -58,6 +70,15 @@ def _input(request: Request, current: SupportPortal | None = None) -> PortalInpu
current.default_locale if current else "ru",
)
),
widget_channel_id=(
_optional_id(request.data["widgetChannelId"])
if request.data.get("widgetChannelId") not in (None, "")
else (
current.widget_channel_id
if current is not None and "widgetChannelId" not in request.data
else None
)
),
)
@@ -198,7 +219,29 @@ class PortalSupportChannelsView(PortalBaseView):
)
.order_by("product__name", "name", "id")
)
return Response({"items": [channel_payload(channel) for channel in channels]})
widget_channels = (
Channel.objects.select_related("department", "product")
.filter(
organization=request.tenant_context.organization,
department__code="support",
is_active=True,
requires_authenticated_product_identity=False,
allow_anonymous_sessions=True,
connections__provider=IntegrationProvider.WEB,
connections__status=IntegrationStatus.OK,
connections__is_active=True,
)
.distinct()
.order_by("name", "id")
)
return Response(
{
"items": [channel_payload(channel) for channel in channels],
"widgetItems": [
channel_payload(channel) for channel in widget_channels
],
}
)
class PortalDomainView(PortalBaseView):
@@ -10,7 +10,7 @@ def portals_for_context(context: TenantContext) -> QuerySet[SupportPortal]:
organization=context.organization,
department__code="support",
)
.select_related("department")
.select_related("department", "widget_channel")
.prefetch_related("product_links__product", "product_links__support_channel")
)
@@ -1,3 +1,5 @@
from django.conf import settings
from hub_platform.support_portals.models import (
PortalArticle,
PortalArticleRevision,
@@ -5,6 +7,7 @@ from hub_platform.support_portals.models import (
SupportPortal,
SupportPortalProduct,
)
from hub_platform.integrations.models import IntegrationProvider, IntegrationStatus
from hub_platform.support_portals.addressing import portal_public_url
from hub_platform.support_portals.domain_services import (
domain_verification_name,
@@ -38,6 +41,15 @@ def portal_payload(portal: SupportPortal) -> dict:
"slug": portal.slug,
"hostedDomain": portal.hosted_domain,
"customDomain": portal.custom_domain or None,
"customDomainAddress": (
{
"name": portal.custom_domain,
"type": "A",
"value": settings.CUS_HELP_PUBLIC_IPV4,
}
if portal.custom_domain and settings.CUS_HELP_PUBLIC_IPV4
else None
),
"customDomainVerifiedAt": portal.custom_domain_verified_at,
"customDomainVerification": (
{
@@ -53,6 +65,8 @@ def portal_payload(portal: SupportPortal) -> dict:
"defaultLocale": portal.default_locale,
"status": portal.status,
"publishedAt": portal.published_at,
"widgetChannelId": portal.widget_channel_id,
"widgetChannelCode": _public_widget_channel_code(portal),
"products": [product_link_payload(link) for link in portal.product_links.all()],
"createdAt": portal.created_at,
"updatedAt": portal.updated_at,
@@ -122,6 +136,7 @@ def public_portal_payload(portal: SupportPortal) -> dict:
"slug": portal.slug,
"name": portal.name,
"defaultLocale": portal.default_locale,
"webWidgetChannelCode": _public_widget_channel_code(portal),
"products": [
{
"code": link.product.code,
@@ -137,6 +152,23 @@ def public_portal_payload(portal: SupportPortal) -> dict:
}
def _public_widget_channel_code(portal: SupportPortal) -> str | None:
channel = portal.widget_channel
if (
channel is None
or not channel.is_active
or channel.requires_authenticated_product_identity
or not channel.allow_anonymous_sessions
):
return None
available = channel.connections.filter(
provider=IntegrationProvider.WEB,
status=IntegrationStatus.OK,
is_active=True,
).exists()
return channel.code if available else None
def public_article_payload(article: PortalArticle, *, content: bool = True) -> dict:
return {
"slug": article.slug,
@@ -14,12 +14,88 @@ from hub_platform.identity.models import (
Organization,
OrganizationMembership,
)
from hub_platform.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
IntegrationStatus,
)
from hub_platform.subscriptions.testing import create_test_subscription
from hub_platform.support_portals.models import SupportPortal, SupportPortalProduct
from hub_platform.support_portals.tests.base import SupportPortalTestCase
class SupportPortalManagementTests(SupportPortalTestCase):
def test_anonymous_web_channel_can_be_attached_as_portal_widget(self) -> None:
portal_id = self.create_portal().json()["portal"]["id"]
widget_channel = Channel.objects.create(
organization=self.organization,
code="foxray-portal-chat",
name="FoxRay — чат портала",
department=self.channel.department,
product=self.product,
requires_authenticated_product_identity=False,
allow_anonymous_sessions=True,
allow_self_reported_contact=True,
)
Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.WEB,
name="FoxRay portal widget",
channel=widget_channel,
status=IntegrationStatus.OK,
)
options = self.client.get(
f"/api/v1/support/portals/{portal_id}/support-channels/"
)
self.assertEqual(options.status_code, 200, options.content)
self.assertEqual(
[item["id"] for item in options.json()["widgetItems"]],
[widget_channel.id],
)
updated = self.client.patch(
f"/api/v1/support/portals/{portal_id}/",
{"widgetChannelId": widget_channel.id},
format="json",
)
self.assertEqual(updated.status_code, 200, updated.content)
self.assertEqual(
updated.json()["portal"]["widgetChannelCode"],
widget_channel.code,
)
config = self.client.get(
f"/api/v1/webchat/config/?channel={widget_channel.code}",
HTTP_ORIGIN="http://foxray-help.localhost",
)
self.assertEqual(config.status_code, 200, config.content)
self.assertTrue(config.json()["available"])
def test_authenticated_support_channel_is_rejected_by_public_webchat(self) -> None:
Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.WEB,
name="Authenticated support widget",
channel=self.channel,
status=IntegrationStatus.OK,
)
config = self.client.get(
f"/api/v1/webchat/config/?channel={self.channel.code}",
)
session = self.client.post(
"/api/v1/webchat/session/",
{"channel": self.channel.code},
format="json",
)
self.assertFalse(config.json()["available"])
self.assertEqual(session.status_code, 404, session.content)
def test_subscription_allows_multiple_active_portals(self) -> None:
first = self.create_portal()
self.assertEqual(first.status_code, 201, first.content)
@@ -85,7 +161,8 @@ class SupportPortalManagementTests(SupportPortalTestCase):
link.refresh_from_db()
self.assertEqual(link.support_channel, self.channel)
def test_custom_domain_requires_matching_dns_record(self) -> None:
@override_settings(CUS_HELP_PUBLIC_IPV4="203.0.113.42")
def test_custom_domain_requires_matching_dns_records(self) -> None:
portal_id = self.create_portal().json()["portal"]["id"]
configured = self.client.put(
f"/api/v1/support/portals/{portal_id}/domain/",
@@ -94,9 +171,23 @@ class SupportPortalManagementTests(SupportPortalTestCase):
)
self.assertEqual(configured.status_code, 200, configured.content)
verification = configured.json()["portal"]["customDomainVerification"]
answer = mock.Mock(strings=[verification["value"].encode("utf-8")])
self.assertEqual(
configured.json()["portal"]["customDomainAddress"],
{
"name": "help.customer.example",
"type": "A",
"value": "203.0.113.42",
},
)
address_answer = mock.Mock(address="203.0.113.42")
verification_answer = mock.Mock(
strings=[verification["value"].encode("utf-8")]
)
with mock.patch("dns.resolver.resolve", return_value=[answer]):
with mock.patch(
"dns.resolver.resolve",
side_effect=[[address_answer], [verification_answer]],
) as resolve:
verified = self.client.post(
f"/api/v1/support/portals/{portal_id}/domain/verify/",
{},
@@ -106,6 +197,35 @@ class SupportPortalManagementTests(SupportPortalTestCase):
self.assertEqual(verified.status_code, 200, verified.content)
self.assertIsNotNone(verified.json()["portal"]["customDomainVerifiedAt"])
self.assertIsNone(verified.json()["portal"]["customDomainVerification"])
self.assertEqual(
resolve.call_args_list,
[
mock.call("help.customer.example", "A"),
mock.call("_custocrm.help.customer.example", "TXT"),
],
)
@override_settings(CUS_HELP_PUBLIC_IPV4="203.0.113.42")
def test_custom_domain_rejects_wrong_address_record(self) -> None:
portal_id = self.create_portal().json()["portal"]["id"]
self.client.put(
f"/api/v1/support/portals/{portal_id}/domain/",
{"customDomain": "help.customer.example"},
format="json",
)
with mock.patch(
"dns.resolver.resolve",
return_value=[mock.Mock(address="203.0.113.99")],
):
response = self.client.post(
f"/api/v1/support/portals/{portal_id}/domain/verify/",
{},
format="json",
)
self.assertEqual(response.status_code, 400, response.content)
self.assertIn("указывает не на сервер CustoCRM", str(response.json()))
@override_settings(CUS_APP_PRIMARY_HOSTS=["app.customer.example"])
def test_custom_domain_cannot_shadow_application_host(self) -> None:
@@ -2,6 +2,13 @@ from django.test import TestCase, override_settings
from django.utils import timezone
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.channels.models import Channel
from hub_platform.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
IntegrationStatus,
)
from hub_platform.support_portals.models import PortalArticleFeedback, SupportPortal
from hub_platform.testing import TenantAPIClient
@@ -21,6 +28,27 @@ class PublicSupportPortalTests(TestCase):
).json()["portal"]
self.portal_id = portal["id"]
self.portal_host = portal["hostedDomain"]
widget_channel = Channel.objects.create(
organization=result.organization,
code="edevs-help-chat",
name="Edevs Help — чат",
department=result.support_department,
requires_authenticated_product_identity=False,
allow_anonymous_sessions=True,
)
Integration.objects.create(
organization=result.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.WEB,
name="Edevs Help widget",
channel=widget_channel,
status=IntegrationStatus.OK,
)
self.client.patch(
f"/api/v1/support/portals/{self.portal_id}/",
{"widgetChannelId": widget_channel.id},
format="json",
)
category = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/categories/",
{
@@ -71,6 +99,10 @@ class PublicSupportPortalTests(TestCase):
portal = self.client.get("/api/v1/help/", HTTP_HOST=self.portal_host)
self.assertEqual(portal.status_code, 200, portal.content)
self.assertEqual(portal.json()["portal"]["name"], "Edevs Help")
self.assertEqual(
portal.json()["portal"]["webWidgetChannelCode"],
"edevs-help-chat",
)
self.assertEqual(
portal.json()["categories"][0]["description"],
"Быстрый старт и основные возможности",
@@ -128,3 +160,33 @@ class PublicSupportPortalTests(TestCase):
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.json()["portal"]["name"], "Edevs Help")
@override_settings(ROOT_URLCONF="hub_backend.urls_platform")
def test_gateway_authorizes_only_published_portal_domains(self) -> None:
custom_domain = "help.foxray.example"
SupportPortal.objects.filter(id=self.portal_id).update(
status="PUBLISHED",
custom_domain=custom_domain,
)
hosted = self.client.get(
"/api/v1/gateway/help-domain/",
{"domain": self.portal_host},
)
unverified = self.client.get(
"/api/v1/gateway/help-domain/",
{"domain": custom_domain},
)
self.assertEqual(hosted.status_code, 204, hosted.content)
self.assertEqual(unverified.status_code, 404, unverified.content)
SupportPortal.objects.filter(id=self.portal_id).update(
custom_domain_verified_at=timezone.now(),
)
verified = self.client.get(
"/api/v1/gateway/help-domain/",
{"domain": custom_domain},
)
self.assertEqual(verified.status_code, 204, verified.content)
@@ -51,6 +51,7 @@ def _resolved_web_connection(channel_code: str):
id=route.resource_id,
organization=organization,
provider=IntegrationProvider.WEB,
is_active=True,
channel__organization=organization,
channel__code=channel_code,
channel__is_active=True,
@@ -86,6 +87,11 @@ class WebchatConfigView(_Public):
with _resolved_web_connection(channel_code) as (context, integration):
if context is None or integration is None:
return Response({"available": False})
if (
integration.channel.requires_authenticated_product_identity
or not integration.channel.allow_anonymous_sessions
):
return Response({"available": False})
return Response(
services.public_config(
context=context,
@@ -101,6 +107,11 @@ class WebchatSessionView(_Public):
with _resolved_web_connection(channel_code) as (context, integration):
if context is None or integration is None:
return Response({"detail": "Канал недоступен"}, status=404)
if (
integration.channel.requires_authenticated_product_identity
or not integration.channel.allow_anonymous_sessions
):
return Response({"detail": "Канал недоступен"}, status=404)
result = services.issue_session(context=context, integration=integration)
if result is None:
return Response({"detail": "Канал недоступен"}, status=404)
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { LogoIcon } from "../../shared/icons";
import { HelpSearch } from "./HelpSearch";
import { PortalWebWidget } from "./PortalWebWidget";
import { SupportLauncher } from "./SupportLauncher";
import type { HelpPortal } from "./types";
@@ -52,7 +53,9 @@ export function HelpLayout({
</a>
<span>База знаний и поддержка</span>
</footer>
<SupportLauncher products={portal.products} />
{portal.webWidgetChannelCode
? <PortalWebWidget channelCode={portal.webWidgetChannelCode} />
: <SupportLauncher products={portal.products} />}
</div>
);
}
@@ -0,0 +1,17 @@
import { useEffect } from "react";
const SCRIPT_ID = "custocrm-help-widget-loader";
export function PortalWebWidget({ channelCode }: { channelCode: string }) {
useEffect(() => {
if (document.getElementById(SCRIPT_ID)) return;
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.src = "/chat-widget.js";
script.async = true;
script.dataset.channel = channelCode;
document.body.appendChild(script);
}, [channelCode]);
return null;
}
@@ -38,6 +38,7 @@ export type HelpPortal = {
slug: string;
name: string;
defaultLocale: string;
webWidgetChannelCode: string | null;
products: HelpProduct[];
};
@@ -0,0 +1,77 @@
import { Dropdown } from "antd";
import { Icon } from "../../shared/icons";
import { StatusPill } from "../../shared/ui";
import { formatDate } from "../../shared/utils";
import type { PortalArticle } from "./model";
export function PortalArticleTable({
articles,
canManage,
onArchive,
onEdit,
}: {
articles: PortalArticle[];
canManage: boolean;
onArchive: (article: PortalArticle) => void;
onEdit: (article: PortalArticle) => void;
}) {
return (
<table className="baseline-table knowledge-table portal-article-table">
<colgroup>
<col className="portal-article-col-title" />
<col className="portal-article-col-category" />
<col className="portal-article-col-language" />
<col className="portal-article-col-version" />
<col className="portal-article-col-status" />
<col className="portal-article-col-updated" />
<col className="portal-article-col-actions" />
</colgroup>
<thead><tr><th>СТАТЬЯ</th><th>РАЗДЕЛ</th><th>ЯЗЫК</th><th>ВЕРСИЯ</th><th>СТАТУС</th><th>ОБНОВЛЕНО</th><th /></tr></thead>
<tbody>
{articles.map((article) => {
const title = article.latestRevision?.title || article.slug;
const menuItems = [
{
key: "edit",
label: <button type="button" onClick={() => onEdit(article)}><Icon name="edit" size={15} />Изменить</button>,
},
...(article.status !== "ARCHIVED" ? [{
key: "archive",
label: <button className="danger" type="button" onClick={() => onArchive(article)}><Icon name="trash" size={15} />В архив</button>,
}] : []),
];
return (
<tr className="knowledge-row" key={article.id} onClick={() => onEdit(article)}>
<td>
<button
className="link is-strong is-neutral"
type="button"
onClick={(event) => {
event.stopPropagation();
onEdit(article);
}}
>
{title}
</button>
<small className="knowledge-description">{article.latestRevision?.summary || article.slug}</small>
</td>
<td className="knowledge-category-path">{article.category.name}</td>
<td>{article.locale.toLocaleUpperCase()}</td>
<td>{article.publishedRevision ? article.publishedRevision.revision : "—"}</td>
<td><StatusPill status={article.status === "PUBLISHED" ? "published" : article.status === "ARCHIVED" ? "archived" : "draft"} /></td>
<td>{formatDate(article.updatedAt)}</td>
<td className="row-actions" onClick={(event) => event.stopPropagation()}>
{canManage && (
<Dropdown menu={{ items: menuItems }} overlayClassName="app-dropdown" placement="bottomRight" trigger={["click"]}>
<button aria-label={`Действия: ${title}`} className="row-menu-button" type="button"><Icon name="more" size={18} /></button>
</Dropdown>
)}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
@@ -1,15 +1,12 @@
import { Dropdown } from "antd";
import { useMemo, useState } from "react";
import { CategoryTree } from "../../shared/content-library/CategoryTree";
import { ContentLibraryTable } from "../../shared/content-library/ContentLibraryTable";
import { ContentLibraryToolbar } from "../../shared/content-library/ContentLibraryToolbar";
import { DecisionDialog } from "../../shared/DecisionDialog";
import { Icon } from "../../shared/icons";
import { StatusPill } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import { formatDate } from "../../shared/utils";
import { PortalArticleEditor } from "./PortalArticleEditor";
import { PortalArticleTable } from "./PortalArticleTable";
import { PortalCategoryManagement } from "./PortalCategoryManagement";
import {
archivePortalArticle,
@@ -150,35 +147,12 @@ export function PortalContent({
onRetry={() => void reload()}
footer={<div className="ai-table-footer"><span>{filtered.length} материалов</span></div>}
>
<table className="baseline-table knowledge-table">
<thead><tr><th>СТАТЬЯ</th><th>РАЗДЕЛ</th><th>ЯЗЫК</th><th>ВЕРСИЯ</th><th>СТАТУС</th><th>ОБНОВЛЕНО</th><th /></tr></thead>
<tbody>
{filtered.map((article) => (
<tr className="knowledge-row" key={article.id} onClick={() => setEditing(article)}>
<td><button className="link is-strong is-neutral" type="button" onClick={() => setEditing(article)}>{article.latestRevision?.title || article.slug}</button><small className="knowledge-description">{article.latestRevision?.summary || article.slug}</small></td>
<td className="knowledge-category-path">{article.category.name}</td>
<td>{article.locale.toLocaleUpperCase()}</td>
<td>{article.publishedRevision ? article.publishedRevision.revision : "—"}</td>
<td><StatusPill status={article.status === "PUBLISHED" ? "published" : article.status === "ARCHIVED" ? "archived" : "draft"} /></td>
<td>{formatDate(article.updatedAt)}</td>
<td onClick={(event) => event.stopPropagation()}>
{canManage && (
<Dropdown
overlayClassName="app-dropdown"
trigger={["click"]}
menu={{ items: [
{ key: "edit", label: "Изменить", onClick: () => setEditing(article) },
...(article.status !== "ARCHIVED" ? [{ key: "archive", danger: true, label: "В архив", onClick: () => setDecision({ article, type: "archive" as const }) }] : []),
] }}
>
<button aria-label={`Действия: ${article.latestRevision?.title ?? article.slug}`} className="row-menu-button" type="button"><Icon name="more" size={18} /></button>
</Dropdown>
)}
</td>
</tr>
))}
</tbody>
</table>
<PortalArticleTable
articles={filtered}
canManage={canManage}
onArchive={(article) => setDecision({ article, type: "archive" })}
onEdit={setEditing}
/>
</ContentLibraryTable>
</main>
</div>
@@ -30,7 +30,7 @@ export function PortalDomainSettings({
try {
const payload = await setPortalCustomDomain(portal.id, domain);
onChanged(payload.portal);
setFeedback(domain.trim() ? "Домен сохранён. Добавьте TXT-запись для подтверждения." : "Свой домен отключён");
setFeedback(domain.trim() ? "Домен сохранён. Добавьте показанные DNS-записи и проверьте домен." : "Свой домен отключён");
} catch (error) {
setFeedback(portalErrorMessage(error, "Не удалось сохранить домен"));
} finally {
@@ -68,14 +68,29 @@ export function PortalDomainSettings({
wide
/>
</div>
{portal.customDomainVerification && (
{(portal.customDomainAddress || portal.customDomainVerification) && (
<div className="portal-domain-verification">
<strong>Добавьте DNS-запись</strong>
<dl>
<div><dt>Тип</dt><dd>{portal.customDomainVerification.type}</dd></div>
<div><dt>Имя</dt><dd><code>{portal.customDomainVerification.name}</code></dd></div>
<div><dt>Значение</dt><dd><code>{portal.customDomainVerification.value}</code></dd></div>
</dl>
<strong>DNS-записи для {portal.customDomain}</strong>
{portal.customDomainAddress && (
<div className="portal-domain-record">
<b>A-запись</b>
<dl>
<div><dt>Тип</dt><dd>{portal.customDomainAddress.type}</dd></div>
<div><dt>Имя</dt><dd><code>{portal.customDomainAddress.name}</code></dd></div>
<div><dt>Значение</dt><dd><code>{portal.customDomainAddress.value}</code></dd></div>
</dl>
</div>
)}
{portal.customDomainVerification && (
<div className="portal-domain-record">
<b>TXT-запись</b>
<dl>
<div><dt>Тип</dt><dd>{portal.customDomainVerification.type}</dd></div>
<div><dt>Имя</dt><dd><code>{portal.customDomainVerification.name}</code></dd></div>
<div><dt>Значение</dt><dd><code>{portal.customDomainVerification.value}</code></dd></div>
</dl>
</div>
)}
</div>
)}
<div className="portal-domain-actions">
@@ -0,0 +1,116 @@
import { useEffect, useMemo, useState } from "react";
import type { Channel } from "../channels/types";
import { SelectField, SwitchButton } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import type { Product } from "../../types";
import {
portalErrorMessage,
replacePortalProducts,
type SupportPortal,
} from "./model";
export function PortalProductSettings({
portal,
products,
channels,
canManage,
onChanged,
}: {
portal: SupportPortal;
products: Product[];
channels: Channel[];
canManage: boolean;
onChanged: (portal: SupportPortal) => void;
}) {
const [selected, setSelected] = useState<Record<number, number | null>>({});
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState("");
useEffect(() => {
setSelected(Object.fromEntries(
portal.products.map((item) => [item.productId, item.supportChannelId]),
));
}, [portal.products]);
const supportChannels = useMemo(() => channels.filter((channel) => (
channel.isActive
&& channel.department === "support"
&& channel.product
&& channel.policy.requiresAuthenticatedProductIdentity
&& !channel.policy.allowAnonymousSessions
)), [channels]);
function toggleProduct(productId: number) {
setSelected((current) => {
const next = { ...current };
if (productId in next) delete next[productId];
else next[productId] = null;
return next;
});
}
async function save() {
setBusy(true);
setFeedback("");
try {
const items = Object.entries(selected).map(([productId, supportChannelId], index) => ({
productId: Number(productId),
supportChannelId,
sortOrder: index,
}));
const payload = await replacePortalProducts(portal.id, items);
onChanged(payload.portal);
setFeedback("Продукты портала обновлены");
} catch (caught) {
setFeedback(portalErrorMessage(caught, "Не удалось сохранить продукты"));
} finally {
setBusy(false);
}
}
return (
<section className="portal-section">
<div className="portal-section-heading">
<div><h2>Продукты и поддержка</h2><p>Выберите продукты и каналы, в которых клиенты смогут обратиться в поддержку.</p></div>
</div>
<div className="portal-product-list">
{products.map((product) => {
const enabled = product.id in selected;
const options: Array<[string, string]> = [
["", "Без перехода в поддержку"],
...supportChannels
.filter((channel) => channel.product?.id === product.id)
.map((channel): [string, string] => [String(channel.id), channel.name]),
];
return (
<div className={enabled ? "is-enabled" : ""} key={product.id}>
<SwitchButton
checked={enabled}
className="ui-switch"
disabled={!canManage}
label={`Добавить ${product.name}`}
onClick={() => toggleProduct(product.id)}
/>
<span><strong>{product.name}</strong><small>{product.code}</small></span>
{enabled && (
<SelectField
disabled={!canManage}
label="Канал поддержки"
value={String(selected[product.id] ?? "")}
onChange={(value) => setSelected((current) => ({
...current,
[product.id]: value ? Number(value) : null,
}))}
options={options}
/>
)}
</div>
);
})}
</div>
{canManage && <Button variant="secondary" disabled={busy} onClick={() => void save()}>Сохранить продукты</Button>}
{feedback && <div className="portal-save-feedback">{feedback}</div>}
</section>
);
}
@@ -1,17 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import type { Channel } from "../channels/types";
import { FormField, SelectField, SwitchButton } from "../../shared/form-controls";
import { FormField, SelectField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import type { Product } from "../../types";
import {
listPortalSupportChannels,
portalErrorMessage,
replacePortalProducts,
updateSupportPortal,
type SupportPortal,
} from "./model";
import { PortalDomainSettings } from "./PortalDomainSettings";
import { PortalProductSettings } from "./PortalProductSettings";
import { PortalWidgetSettings } from "./PortalWidgetSettings";
export function PortalSettings({
portal,
@@ -28,7 +29,7 @@ export function PortalSettings({
const [slug, setSlug] = useState(portal.slug);
const [locale, setLocale] = useState(portal.defaultLocale);
const [channels, setChannels] = useState<Channel[]>([]);
const [selected, setSelected] = useState<Record<number, number | null>>({});
const [widgetChannels, setWidgetChannels] = useState<Channel[]>([]);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState("");
@@ -36,25 +37,20 @@ export function PortalSettings({
setName(portal.name);
setSlug(portal.slug);
setLocale(portal.defaultLocale);
setSelected(Object.fromEntries(
portal.products.map((item) => [item.productId, item.supportChannelId]),
));
}, [portal]);
useEffect(() => {
listPortalSupportChannels(portal.id)
.then((payload) => setChannels(payload.items))
.catch(() => setChannels([]));
.then((payload) => {
setChannels(payload.items);
setWidgetChannels(payload.widgetItems);
})
.catch(() => {
setChannels([]);
setWidgetChannels([]);
});
}, [portal.id]);
const supportChannels = useMemo(() => channels.filter((channel) => (
channel.isActive
&& channel.department === "support"
&& channel.product
&& channel.policy.requiresAuthenticatedProductIdentity
&& !channel.policy.allowAnonymousSessions
)), [channels]);
async function saveBasics() {
setBusy(true);
setFeedback("");
@@ -73,34 +69,6 @@ export function PortalSettings({
}
}
async function saveProducts() {
setBusy(true);
setFeedback("");
try {
const items = Object.entries(selected).map(([productId, supportChannelId], index) => ({
productId: Number(productId),
supportChannelId,
sortOrder: index,
}));
const payload = await replacePortalProducts(portal.id, items);
onChanged(payload.portal);
setFeedback("Продукты портала обновлены");
} catch (caught) {
setFeedback(portalErrorMessage(caught, "Не удалось сохранить продукты"));
} finally {
setBusy(false);
}
}
function toggleProduct(productId: number) {
setSelected((current) => {
const next = { ...current };
if (productId in next) delete next[productId];
else next[productId] = null;
return next;
});
}
return (
<div className="portal-settings-grid">
<section className="portal-section">
@@ -122,47 +90,19 @@ export function PortalSettings({
{canManage && <Button variant="primary" disabled={busy} onClick={() => void saveBasics()}>Сохранить настройки</Button>}
</section>
<section className="portal-section">
<div className="portal-section-heading">
<div><h2>Продукты и поддержка</h2><p>Выберите продукты и каналы, в которых клиенты смогут обратиться в поддержку.</p></div>
</div>
<div className="portal-product-list">
{products.map((product) => {
const enabled = product.id in selected;
const options: Array<[string, string]> = [
["", "Без перехода в поддержку"],
...supportChannels
.filter((channel) => channel.product?.id === product.id)
.map((channel): [string, string] => [String(channel.id), channel.name]),
];
return (
<div className={enabled ? "is-enabled" : ""} key={product.id}>
<SwitchButton
checked={enabled}
className="ui-switch"
disabled={!canManage}
label={`Добавить ${product.name}`}
onClick={() => toggleProduct(product.id)}
/>
<span><strong>{product.name}</strong><small>{product.code}</small></span>
{enabled && (
<SelectField
disabled={!canManage}
label="Канал поддержки"
value={String(selected[product.id] ?? "")}
onChange={(value) => setSelected((current) => ({
...current,
[product.id]: value ? Number(value) : null,
}))}
options={options}
/>
)}
</div>
);
})}
</div>
{canManage && <Button variant="secondary" disabled={busy} onClick={() => void saveProducts()}>Сохранить продукты</Button>}
</section>
<PortalProductSettings
canManage={canManage}
channels={channels}
portal={portal}
products={products}
onChanged={onChanged}
/>
<PortalWidgetSettings
canManage={canManage}
channels={widgetChannels}
portal={portal}
onChanged={onChanged}
/>
<PortalDomainSettings portal={portal} canManage={canManage} onChanged={onChanged} />
{feedback && <div className="portal-save-feedback">{feedback}</div>}
</div>
@@ -0,0 +1,76 @@
import { useEffect, useState } from "react";
import type { Channel } from "../channels/types";
import { SelectField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import {
portalErrorMessage,
updateSupportPortal,
type SupportPortal,
} from "./model";
export function PortalWidgetSettings({
canManage,
channels,
portal,
onChanged,
}: {
canManage: boolean;
channels: Channel[];
portal: SupportPortal;
onChanged: (portal: SupportPortal) => void;
}) {
const [channelId, setChannelId] = useState(portal.widgetChannelId);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState("");
useEffect(() => setChannelId(portal.widgetChannelId), [portal.widgetChannelId]);
async function save() {
setBusy(true);
setFeedback("");
try {
const payload = await updateSupportPortal(portal.id, {
widgetChannelId: channelId,
});
onChanged(payload.portal);
setFeedback("Веб-виджет обновлён");
} catch (caught) {
setFeedback(portalErrorMessage(caught, "Не удалось сохранить веб-виджет"));
} finally {
setBusy(false);
}
}
return (
<section className="portal-section">
<div className="portal-section-heading">
<div>
<h2>Веб-виджет</h2>
<p>Публичный чат отображается на всех страницах портала.</p>
</div>
</div>
<div className="portal-settings-fields">
<SelectField
disabled={!canManage}
label="Канал виджета"
value={String(channelId ?? "")}
onChange={(value) => setChannelId(value ? Number(value) : null)}
options={[
["", "Не показывать"],
...channels.map((channel): [string, string] => [
String(channel.id),
channel.name,
]),
]}
/>
</div>
{canManage && (
<Button variant="secondary" disabled={busy} onClick={() => void save()}>
Сохранить виджет
</Button>
)}
{feedback && <div className="portal-save-feedback">{feedback}</div>}
</section>
);
}
@@ -0,0 +1,184 @@
import { api } from "../../api/client";
import type { Channel } from "../channels/types";
import type {
ArticleRevision,
PortalArticle,
PortalCategory,
PortalInput,
PortalStatus,
SupportPortal,
SupportPortalList,
} from "./model";
export function listSupportPortals(): Promise<SupportPortalList> {
return api("/api/v1/support/portals/");
}
export function createSupportPortal(input: PortalInput): Promise<{ portal: SupportPortal }> {
return api("/api/v1/support/portals/", {
method: "POST",
body: JSON.stringify(input),
});
}
export function loadSupportPortal(id: number): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${id}/`);
}
export function updateSupportPortal(
id: number,
input: Partial<PortalInput>,
): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${id}/`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function changePortalStatus(
id: number,
status: PortalStatus,
): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${id}/status/`, {
method: "POST",
body: JSON.stringify({ status }),
});
}
export function replacePortalProducts(
id: number,
items: Array<{ productId: number; supportChannelId: number | null; sortOrder: number }>,
): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${id}/products/`, {
method: "PUT",
body: JSON.stringify({ items }),
});
}
export function listPortalCategories(id: number): Promise<{ items: PortalCategory[] }> {
return api(`/api/v1/support/portals/${id}/categories/`);
}
export function createPortalCategory(
id: number,
input: Pick<PortalCategory, "name"> & Partial<Omit<PortalCategory, "id" | "articleCount" | "name">>,
): Promise<{ category: PortalCategory }> {
return api(`/api/v1/support/portals/${id}/categories/`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function updatePortalCategory(
portalId: number,
categoryId: number,
input: Partial<Omit<PortalCategory, "id" | "articleCount">>,
): Promise<{ category: PortalCategory }> {
return api(`/api/v1/support/portals/${portalId}/categories/${categoryId}/`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deletePortalCategory(
portalId: number,
categoryId: number,
): Promise<void> {
return api(`/api/v1/support/portals/${portalId}/categories/${categoryId}/`, {
method: "DELETE",
});
}
export function listPortalSupportChannels(
portalId: number,
): Promise<{ items: Channel[]; widgetItems: Channel[] }> {
return api(`/api/v1/support/portals/${portalId}/support-channels/`);
}
export function setPortalCustomDomain(
portalId: number,
customDomain: string,
): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${portalId}/domain/`, {
method: "PUT",
body: JSON.stringify({ customDomain }),
});
}
export function verifyPortalCustomDomain(
portalId: number,
): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${portalId}/domain/verify/`, {
method: "POST",
});
}
export function listPortalArticles(id: number): Promise<{ items: PortalArticle[] }> {
return api(`/api/v1/support/portals/${id}/articles/`);
}
export function createPortalArticle(
portalId: number,
input: {
categoryId: number;
slug: string;
locale: string;
title: string;
summary: string;
content: string;
},
): Promise<{ article: PortalArticle }> {
return api(`/api/v1/support/portals/${portalId}/articles/`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function loadPortalArticle(
portalId: number,
articleId: number,
): Promise<{ article: PortalArticle }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/`);
}
export function updatePortalArticle(
portalId: number,
articleId: number,
input: { categoryId: number; locale: string; slug: string },
): Promise<{ article: PortalArticle }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function addArticleRevision(
portalId: number,
articleId: number,
input: Pick<ArticleRevision, "title" | "summary" | "content">,
): Promise<{ revision: ArticleRevision }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/revisions/`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function publishArticleRevision(
portalId: number,
articleId: number,
revisionId: number,
): Promise<{ article: PortalArticle }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/publish/`, {
method: "POST",
body: JSON.stringify({ revisionId }),
});
}
export function archivePortalArticle(
portalId: number,
articleId: number,
): Promise<{ article: PortalArticle }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/archive/`, {
method: "POST",
});
}
@@ -1,5 +1,4 @@
import { api, ApiError } from "../../api/client";
import type { Channel } from "../channels/types";
import { ApiError } from "../../api/client";
export type PortalStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
export type ArticleStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
@@ -20,6 +19,11 @@ export type SupportPortal = {
slug: string;
hostedDomain: string;
customDomain: string | null;
customDomainAddress: {
name: string;
type: "A";
value: string;
} | null;
customDomainVerifiedAt: string | null;
customDomainVerification: {
name: string;
@@ -31,6 +35,8 @@ export type SupportPortal = {
defaultLocale: string;
status: PortalStatus;
publishedAt: string | null;
widgetChannelId: number | null;
widgetChannelCode: string | null;
products: PortalProductLink[];
createdAt: string;
updatedAt: string;
@@ -73,6 +79,7 @@ export type PortalInput = {
slug: string;
name: string;
defaultLocale: string;
widgetChannelId?: number | null;
};
export type PortalCreationPolicy = {
@@ -94,178 +101,28 @@ export type SupportPortalList = {
address: PortalAddressConfig;
};
export function listSupportPortals(): Promise<SupportPortalList> {
return api("/api/v1/support/portals/");
}
export function createSupportPortal(input: PortalInput): Promise<{ portal: SupportPortal }> {
return api("/api/v1/support/portals/", {
method: "POST",
body: JSON.stringify(input),
});
}
export function loadSupportPortal(id: number): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${id}/`);
}
export function updateSupportPortal(
id: number,
input: PortalInput,
): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${id}/`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function changePortalStatus(
id: number,
status: PortalStatus,
): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${id}/status/`, {
method: "POST",
body: JSON.stringify({ status }),
});
}
export function replacePortalProducts(
id: number,
items: Array<{ productId: number; supportChannelId: number | null; sortOrder: number }>,
): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${id}/products/`, {
method: "PUT",
body: JSON.stringify({ items }),
});
}
export function listPortalCategories(id: number): Promise<{ items: PortalCategory[] }> {
return api(`/api/v1/support/portals/${id}/categories/`);
}
export function createPortalCategory(
id: number,
input: Pick<PortalCategory, "name"> & Partial<Omit<PortalCategory, "id" | "articleCount" | "name">>,
): Promise<{ category: PortalCategory }> {
return api(`/api/v1/support/portals/${id}/categories/`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function updatePortalCategory(
portalId: number,
categoryId: number,
input: Partial<Omit<PortalCategory, "id" | "articleCount">>,
): Promise<{ category: PortalCategory }> {
return api(`/api/v1/support/portals/${portalId}/categories/${categoryId}/`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deletePortalCategory(
portalId: number,
categoryId: number,
): Promise<void> {
return api(`/api/v1/support/portals/${portalId}/categories/${categoryId}/`, {
method: "DELETE",
});
}
export function listPortalSupportChannels(
portalId: number,
): Promise<{ items: Channel[] }> {
return api(`/api/v1/support/portals/${portalId}/support-channels/`);
}
export function setPortalCustomDomain(
portalId: number,
customDomain: string,
): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${portalId}/domain/`, {
method: "PUT",
body: JSON.stringify({ customDomain }),
});
}
export function verifyPortalCustomDomain(
portalId: number,
): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${portalId}/domain/verify/`, {
method: "POST",
});
}
export function listPortalArticles(id: number): Promise<{ items: PortalArticle[] }> {
return api(`/api/v1/support/portals/${id}/articles/`);
}
export function createPortalArticle(
portalId: number,
input: {
categoryId: number;
slug: string;
locale: string;
title: string;
summary: string;
content: string;
},
): Promise<{ article: PortalArticle }> {
return api(`/api/v1/support/portals/${portalId}/articles/`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function loadPortalArticle(
portalId: number,
articleId: number,
): Promise<{ article: PortalArticle }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/`);
}
export function updatePortalArticle(
portalId: number,
articleId: number,
input: { categoryId: number; locale: string; slug: string },
): Promise<{ article: PortalArticle }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function addArticleRevision(
portalId: number,
articleId: number,
input: Pick<ArticleRevision, "title" | "summary" | "content">,
): Promise<{ revision: ArticleRevision }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/revisions/`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function publishArticleRevision(
portalId: number,
articleId: number,
revisionId: number,
): Promise<{ article: PortalArticle }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/publish/`, {
method: "POST",
body: JSON.stringify({ revisionId }),
});
}
export function archivePortalArticle(
portalId: number,
articleId: number,
): Promise<{ article: PortalArticle }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/archive/`, {
method: "POST",
});
}
export {
addArticleRevision,
archivePortalArticle,
changePortalStatus,
createPortalArticle,
createPortalCategory,
createSupportPortal,
deletePortalCategory,
listPortalArticles,
listPortalCategories,
listPortalSupportChannels,
listSupportPortals,
loadPortalArticle,
loadSupportPortal,
publishArticleRevision,
replacePortalProducts,
setPortalCustomDomain,
updatePortalArticle,
updatePortalCategory,
updateSupportPortal,
verifyPortalCustomDomain,
} from "./api";
export const PORTAL_STATUS_LABEL: Record<PortalStatus, string> = {
DRAFT: "Черновик",
@@ -75,6 +75,20 @@
font-size: 11.5px;
}
.portal-article-col-title { width: 26%; }
.portal-article-col-category { width: 18%; }
.portal-article-col-language { width: 7%; }
.portal-article-col-version { width: 7%; }
.portal-article-col-status { width: 18%; }
.portal-article-col-updated { width: 17%; }
.portal-article-col-actions { width: 48px; }
.portal-article-table th:last-child,
.portal-article-table td:last-child {
padding-right: 10px;
padding-left: 8px;
}
.portal-product-list {
display: grid;
gap: 8px;
@@ -130,10 +144,19 @@
font-size: 13px;
}
.portal-domain-record {
margin-top: 14px;
}
.portal-domain-record > b {
color: var(--text-heading);
font-size: 12.5px;
}
.portal-domain-verification dl {
display: grid;
gap: 8px;
margin: 12px 0 0;
margin: 8px 0 0;
}
.portal-domain-verification dl > div {
@@ -1,49 +0,0 @@
import type { RouteKey } from "../types";
const ITEMS: Array<{
activeRoutes: RouteKey[];
disabled?: boolean;
key: RouteKey;
label: string;
}> = [
{
activeRoutes: ["aiAgents", "aiAgentCreate", "aiAgentDetail"],
key: "aiAgents",
label: "AI-агенты",
},
{
activeRoutes: ["aiKnowledge", "aiKnowledgeCreate", "aiKnowledgeDetail"],
key: "aiKnowledge",
label: "Знания",
},
{
activeRoutes: ["aiUsage"],
disabled: true,
key: "aiUsage",
label: "Использование AI",
},
];
export function AiSidebarNav({
route,
setRoute,
}: {
route: RouteKey;
setRoute: (route: RouteKey) => void;
}) {
return (
<div className="hub-ai-subnav">
{ITEMS.map((item) => (
<button
className={`hub-ai-nav-item${item.activeRoutes.includes(route) ? " is-active" : ""}`}
disabled={item.disabled}
key={item.key}
type="button"
onClick={() => setRoute(item.key)}
>
{item.label}
</button>
))}
</div>
);
}
@@ -1,42 +0,0 @@
import type { RouteKey, SessionUser } from "../types";
import { Icon } from "../shared/icons";
import { canAccess } from "../auth/access";
import { SidebarUserMenu } from "./SidebarUserMenu";
export function SalesSidebar({ route, user, setRoute, onLogout, waitingCount = 0 }: { route: RouteKey; user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => void; waitingCount?: number }) {
const nav = [
{ key: "salesOverview" as const, label: "Обзор", icon: "grid" as const },
{ key: "salesDialogs" as const, label: "Диалоги", icon: "message" as const, badge: waitingCount > 0 ? String(waitingCount) : undefined },
{ key: "salesClients" as const, label: "Контакты", icon: "team" as const },
{ key: "salesOrders" as const, label: "Продажи", icon: "cart" as const },
];
return (
<aside className="hub-sidebar sales-workspace-sidebar">
{canAccess(user, "command") && (
<div className="sales-sidebar-back-wrap">
<button className="sales-sidebar-back" type="button" onClick={() => setRoute("command")}><Icon name="arrow" size={16} />Назад</button>
</div>
)}
<div className="sales-sidebar-title">
<div className="sales-sidebar-icon"><Icon name="shop" size={19} /></div>
<div><strong>Продажи</strong><span>Рабочее пространство</span></div>
</div>
<nav className="hub-nav sales-workspace-nav">
{nav.map((item) => {
const nextRoute = "key" in item ? item.key : null;
if (nextRoute && !canAccess(user, nextRoute)) return null;
const active = nextRoute === route || (nextRoute === "salesClients" && route === "salesClientDetail") || (nextRoute === "salesOrders" && route === "salesOrderDetail");
return (
<button className={`hub-nav-item ${active ? "is-active" : ""}`} type="button" onClick={() => nextRoute && setRoute(nextRoute)} key={item.label}>
{active && <span className="active-bar" />}
<Icon name={item.icon} />
{item.label}
{item.badge && <b>{item.badge}</b>}
</button>
);
})}
</nav>
<SidebarUserMenu user={user} route={route} setRoute={setRoute} onLogout={onLogout} />
</aside>
);
}
+1 -10
View File
@@ -6,11 +6,8 @@ import { NotificationDrawer } from "../features/notifications/NotificationDrawer
import { fetchNotifications, markAllRead, markRead, type AppNotification } from "../features/notifications/model";
import { fetchWaitingCount } from "../features/conversations/model";
import { Sidebar } from "./Sidebar";
import { SalesSidebar } from "./SalesSidebar";
import { SupportSidebar } from "./SupportSidebar";
import { ShellRouteContent } from "./ShellRouteContent";
import { TopBar } from "./TopBar";
import { canAccess } from "../auth/access";
export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedOrderId, selectedChannelId, selectedSupportPortalId, openChannelRoute, openSupportPortalRoute, openEmployeeRoute, openProductRoute, openAgentCreateRoute, openAgentRoute, openKnowledgeRoute, openConversationRoute, openClientRoute, openOrderRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; selectedEmployeeId: number | null; selectedProductId: number | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedOrderId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; openEmployeeRoute: (employeeId: number) => void; openProductRoute: (productId: number) => void; openAgentCreateRoute: (productCode: string | null) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openOrderRoute: (orderId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
const [agentName, setAgentName] = useState<string | null>(null);
@@ -95,15 +92,9 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId,
const isAiFullWidth = route === "aiAgentCreate";
const isKnowledgeLibrary = route === "aiKnowledge";
const isKnowledgeEditor = route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail";
const showSalesSidebar = isSalesWorkspace || (
canAccess(user, "salesDialogs") && !canAccess(user, "command") && !isSupportWorkspace
);
const showSupportSidebar = isSupportWorkspace || (
canAccess(user, "supportDialogs") && !canAccess(user, "command") && !isSalesWorkspace
);
return (
<div className="hub-shell">
{showSupportSidebar ? <SupportSidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} waitingCount={waitingCount} /> : showSalesSidebar ? <SalesSidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} waitingCount={waitingCount} /> : <Sidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} />}
<Sidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} waitingCount={waitingCount} />
<div className="hub-main">
<TopBar route={route} user={user} currentEmployee={currentEmployee} currentProduct={currentProduct} currentAgentName={agentName} currentChannelName={channelName} setRoute={setRoute} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} />
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""}`}>
+76 -34
View File
@@ -1,47 +1,89 @@
import type { RouteKey, SessionUser } from "../types";
import { Icon, LogoIcon } from "../shared/icons";
import { canAccess } from "../auth/access";
import { AiSidebarNav } from "./AiSidebarNav";
import { canAccess, defaultRoute } from "../auth/access";
import { SidebarNavSection, type SidebarNavSectionItem } from "./SidebarNavSection";
import { SidebarUserMenu } from "./SidebarUserMenu";
export function Sidebar({ route, user, setRoute, onLogout }: { route: RouteKey; user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => 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 },
{ key: "channels" as const, label: "Каналы", icon: "route" as const },
{ label: "ПЛАТФОРМА", group: true },
{ key: "aiAgents" as const, label: "AI", icon: "robot" as const },
{ key: "integrations" as const, label: "Интеграции", icon: "plug" as const },
{ divider: true },
{ label: "Администрирование", icon: "settings" as const, disabled: true },
];
type SidebarLinkProps = {
activeRoutes?: RouteKey[];
disabled?: boolean;
icon: Parameters<typeof Icon>[0]["name"];
label: string;
route: RouteKey;
routeKey?: RouteKey;
setRoute: (route: RouteKey) => void;
};
function SidebarLink({ activeRoutes, disabled, icon, label, route, routeKey, setRoute }: SidebarLinkProps) {
const active = activeRoutes?.includes(route) ?? routeKey === route;
return (
<button
className={`hub-nav-item ${active ? "is-active" : ""}`}
disabled={disabled}
type="button"
onClick={() => routeKey && setRoute(routeKey)}
>
{active && <span className="active-bar" />}
<Icon name={icon} />
{label}
</button>
);
}
const SALES_ITEMS: SidebarNavSectionItem[] = [
{ activeRoutes: ["salesOverview"], key: "salesOverview", label: "Обзор" },
{ activeRoutes: ["salesDialogs"], key: "salesDialogs", label: "Диалоги" },
{ activeRoutes: ["salesClients", "salesClientDetail"], key: "salesClients", label: "Контакты" },
{ activeRoutes: ["salesOrders", "salesOrderDetail"], key: "salesOrders", label: "Продажи" },
];
const SUPPORT_ITEMS: SidebarNavSectionItem[] = [
{ activeRoutes: ["supportOverview"], key: "supportOverview", label: "Обзор" },
{ activeRoutes: ["supportDialogs"], key: "supportDialogs", label: "Диалоги" },
{ activeRoutes: ["supportPortals", "supportPortalDetail"], key: "supportPortals", label: "Порталы" },
];
const AI_ITEMS: SidebarNavSectionItem[] = [
{ activeRoutes: ["aiAgents", "aiAgentCreate", "aiAgentDetail"], key: "aiAgents", label: "AI-агенты" },
{ activeRoutes: ["aiKnowledge", "aiKnowledgeCreate", "aiKnowledgeDetail"], key: "aiKnowledge", label: "Знания" },
{ activeRoutes: ["aiUsage"], disabled: true, key: "aiUsage", label: "Использование AI" },
];
function visibleItems(user: SessionUser, items: SidebarNavSectionItem[]) {
return items.filter((item) => canAccess(user, item.key));
}
export function Sidebar({ route, user, setRoute, onLogout, waitingCount = 0 }: { route: RouteKey; user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => void; waitingCount?: number }) {
const sectionStorageKey = (section: string) => (
`custocrm.sidebar.${user.organizationPublicId}.${section}.expanded`
);
const salesItems = visibleItems(user, SALES_ITEMS).map((item) => (
item.key === "salesDialogs" && waitingCount > 0
? { ...item, badge: String(waitingCount) }
: item
));
const supportItems = visibleItems(user, SUPPORT_ITEMS);
const aiItems = visibleItems(user, AI_ITEMS);
return (
<aside className="hub-sidebar">
<button className="hub-brand" type="button" onClick={() => setRoute("command")}>
<button className="hub-brand" type="button" onClick={() => setRoute(defaultRoute(user))}>
<div className="hub-brand-mark"><LogoIcon /></div>
<div><strong>CustoCRM</strong><span>Управление компанией</span></div>
</button>
<nav className="hub-nav">
{nav.map((item, index) => {
if ("group" in item) return <div className="hub-nav-group" key={item.label}>{item.label}</div>;
if ("divider" in item) return <div className="hub-nav-divider" key={index} />;
const nextRoute = "key" in item ? item.key : null;
if (nextRoute && !canAccess(user, nextRoute)) return null;
const active = nextRoute === route || (nextRoute === "employees" && (route === "employeeDetail" || route === "accessProfiles")) || (nextRoute === "products" && route === "productDetail") || (nextRoute === "aiAgents" && route.startsWith("ai")) || (nextRoute === "channels" && route.startsWith("channel"));
return (
<div key={item.label}>
<button className={`hub-nav-item ${active ? "is-active" : ""}`} disabled={item.disabled} onClick={() => nextRoute && setRoute(nextRoute)}>
{active && <span className="active-bar" />}
<Icon name={item.icon} />
{item.label}
</button>
{nextRoute === "aiAgents" && active && <AiSidebarNav route={route} setRoute={setRoute} />}
</div>
);
})}
{canAccess(user, "command") && <SidebarLink icon="grid" label="Обзор" route={route} routeKey="command" setRoute={setRoute} />}
<div className="hub-nav-group">КОМПАНИЯ</div>
<SidebarNavSection icon="shop" items={salesItems} label="Продажи" route={route} setRoute={setRoute} storageKey={sectionStorageKey("sales")} />
<SidebarNavSection icon="wrench" items={supportItems} label="Поддержка" route={route} setRoute={setRoute} storageKey={sectionStorageKey("support")} />
{canAccess(user, "employees") && <SidebarLink activeRoutes={["employees", "employeeDetail", "accessProfiles"]} icon="team" label="Сотрудники" route={route} routeKey="employees" setRoute={setRoute} />}
{canAccess(user, "products") && <SidebarLink activeRoutes={["products", "productDetail"]} icon="box" label="Продукты" route={route} routeKey="products" setRoute={setRoute} />}
{canAccess(user, "channels") && <SidebarLink activeRoutes={["channels", "channelCreate", "channelDetail"]} icon="route" label="Каналы" route={route} routeKey="channels" setRoute={setRoute} />}
<div className="hub-nav-group">ПЛАТФОРМА</div>
<SidebarNavSection icon="robot" items={aiItems} label="AI" route={route} setRoute={setRoute} storageKey={sectionStorageKey("ai")} />
{canAccess(user, "integrations") && <SidebarLink icon="plug" label="Интеграции" route={route} routeKey="integrations" setRoute={setRoute} />}
<div className="hub-nav-divider" />
<SidebarLink disabled icon="settings" label="Администрирование" route={route} setRoute={setRoute} />
</nav>
<SidebarUserMenu user={user} route={route} setRoute={setRoute} onLogout={onLogout} />
</aside>
@@ -0,0 +1,83 @@
import { useState } from "react";
import { Icon } from "../shared/icons";
import type { RouteKey } from "../types";
export type SidebarNavSectionItem = {
activeRoutes: RouteKey[];
badge?: string;
disabled?: boolean;
key: RouteKey;
label: string;
};
export function SidebarNavSection({
icon,
items,
label,
route,
setRoute,
storageKey,
}: {
icon: Parameters<typeof Icon>[0]["name"];
items: SidebarNavSectionItem[];
label: string;
route: RouteKey;
setRoute: (route: RouteKey) => void;
storageKey: string;
}) {
const [expanded, setExpanded] = useState(() => {
try {
return window.localStorage.getItem(storageKey) === "true";
} catch {
return false;
}
});
const active = items.some((item) => item.activeRoutes.includes(route));
if (items.length === 0) return null;
function toggleExpanded() {
setExpanded((current) => {
const next = !current;
try {
window.localStorage.setItem(storageKey, String(next));
} catch {
// Навигация остаётся рабочей, даже если хранилище браузера недоступно.
}
return next;
});
}
return (
<div className="hub-nav-section">
<button
aria-expanded={expanded}
className={`hub-nav-item hub-nav-section-toggle ${active ? "is-active" : ""}`}
type="button"
onClick={toggleExpanded}
>
{active && <span className="active-bar" />}
<Icon name={icon} />
{label}
<span className="hub-nav-chevron"><Icon name="chevron" size={14} /></span>
</button>
{expanded && (
<div className="hub-subnav">
{items.map((item) => (
<button
className={`hub-subnav-item ${item.activeRoutes.includes(route) ? "is-active" : ""}`}
disabled={item.disabled}
key={item.key}
type="button"
onClick={() => setRoute(item.key)}
>
{item.label}
{item.badge && <b>{item.badge}</b>}
</button>
))}
</div>
)}
</div>
);
}
@@ -1,45 +0,0 @@
import type { RouteKey, SessionUser } from "../types";
import { Icon } from "../shared/icons";
import { canAccess } from "../auth/access";
import { SidebarUserMenu } from "./SidebarUserMenu";
// SPEC-HUB-0010 §8.1: вся внутренняя support-навигация живёт в workspace отдела.
// Зеркало SalesSidebar (переиспользование layout/структуры), переиспользует
// существующие CSS-классы sales-workspace-sidebar/nav — дизайн baseline.
export function SupportSidebar({ route, user, setRoute, onLogout, waitingCount = 0 }: { route: RouteKey; user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => void; waitingCount?: number }) {
const nav = [
{ key: "supportOverview" as const, label: "Обзор", icon: "grid" as const },
{ key: "supportDialogs" as const, label: "Диалоги", icon: "message" as const, badge: waitingCount > 0 ? String(waitingCount) : undefined },
{ key: "supportPortals" as const, label: "Порталы", icon: "folder" as const },
];
return (
<aside className="hub-sidebar sales-workspace-sidebar">
{canAccess(user, "command") && (
<div className="sales-sidebar-back-wrap">
<button className="sales-sidebar-back" type="button" onClick={() => setRoute("command")}><Icon name="arrow" size={16} />Назад</button>
</div>
)}
<div className="sales-sidebar-title">
<div className="sales-sidebar-icon"><Icon name="wrench" size={19} /></div>
<div><strong>Поддержка</strong><span>Рабочее пространство</span></div>
</div>
<nav className="hub-nav sales-workspace-nav">
{nav.map((item) => {
if (!canAccess(user, item.key)) return null;
const active = item.key === route || (
item.key === "supportPortals" && route === "supportPortalDetail"
);
return (
<button className={`hub-nav-item ${active ? "is-active" : ""}`} type="button" onClick={() => setRoute(item.key)} key={item.label}>
{active && <span className="active-bar" />}
<Icon name={item.icon} />
{item.label}
{item.badge && <b>{item.badge}</b>}
</button>
);
})}
</nav>
<SidebarUserMenu user={user} route={route} setRoute={setRoute} onLogout={onLogout} />
</aside>
);
}
+32 -97
View File
@@ -102,15 +102,17 @@
font-weight: 600;
}
.hub-ai-subnav {
.hub-subnav {
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px 0 2px 30px;
}
.hub-ai-nav-item {
.hub-subnav-item {
width: 100%;
display: flex;
align-items: center;
padding: 6px 12px;
border: 0;
border-radius: 7px;
@@ -122,22 +124,47 @@
text-align: left;
}
.hub-ai-nav-item:not(:disabled):hover {
.hub-subnav-item:not(:disabled):hover {
color: #262626;
background: #f5f5f5;
}
.hub-ai-nav-item.is-active {
.hub-subnav-item.is-active {
color: #0958d9;
background: #e6f4ff;
font-weight: 600;
}
.hub-ai-nav-item:disabled {
.hub-subnav-item:disabled {
color: #bfbfbf;
cursor: not-allowed;
}
.hub-nav-chevron {
display: inline-flex;
margin-left: auto;
transition: transform 0.15s ease;
}
.hub-nav-section-toggle[aria-expanded="false"] .hub-nav-chevron {
transform: rotate(-90deg);
}
.hub-subnav-item b {
min-width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: auto;
padding: 0 6px;
color: #ffffff;
background: #faad14;
border-radius: 10px;
font-size: 11px;
font-weight: 700;
}
.active-bar {
position: absolute;
left: 0;
@@ -179,98 +206,6 @@
color: #bfbfbf;
}
.sales-workspace-sidebar {
width: 240px;
}
.sales-sidebar-back-wrap {
height: 56px;
display: flex;
align-items: center;
flex: none;
padding: 0 14px;
border-bottom: 1px solid #f5f5f5;
}
.sales-sidebar-back {
width: 100%;
height: 36px;
display: flex;
align-items: center;
gap: 9px;
padding: 0 12px;
color: #595959;
background: #ffffff;
border: 1px solid #f0f0f0;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
}
.sales-sidebar-back:hover {
color: #1677ff;
background: #f5f5f5;
border-color: #d9d9d9;
}
.sales-sidebar-back svg {
transform: rotate(180deg);
}
.sales-sidebar-title {
display: flex;
align-items: center;
gap: 11px;
padding: 16px 16px 8px;
}
.sales-sidebar-icon {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
flex: none;
color: #1677ff;
background: #e6f4ff;
border-radius: 9px;
}
.sales-sidebar-title strong {
display: block;
color: #1f1f1f;
font-size: 15px;
font-weight: 700;
line-height: 1.15;
}
.sales-sidebar-title span {
display: block;
margin-top: 2px;
color: #8c8c8c;
font-size: 11px;
line-height: 1.15;
}
.sales-workspace-nav {
padding-top: 8px;
}
.sales-workspace-nav b {
min-width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: auto;
padding: 0 6px;
color: #ffffff;
background: #faad14;
border-radius: 10px;
font-size: 11px;
font-weight: 700;
}
.avatar {
width: 32px;
height: 32px;
+26
View File
@@ -135,6 +135,32 @@ services:
redis:
condition: service_healthy
# Отдельный импорт демо-контента портала не создаёт пользователей и не
# изменяет пароли, роли, сессии или другие учётные данные.
support-portal-seed:
profiles: ["support-portal-seed"]
build:
context: .
dockerfile: apps/backend/Dockerfile
env_file:
- .env
command: ["python", "/opt/custocrm-local-seed/seed_support_portal.py"]
working_dir: /app/apps/backend
environment:
CUS_DB_ROLE: migration
CUSTOCRM_LOCAL_SEED: "1"
CUS_HELP_BASE_DOMAIN: localhost
CUS_HELP_PUBLIC_SCHEME: http
CUS_HELP_PUBLIC_PORT: ""
volumes:
- ./apps/backend:/app/apps/backend
- ./dev/local-seed:/opt/custocrm-local-seed:ro
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
# Dev: gateway — nginx с local.conf вместо Caddy (та же маршрутизация, HMR-friendly).
gateway:
image: nginx:1.27-alpine
+2 -1
View File
@@ -32,7 +32,8 @@ server {
}
location /chat/ {
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors *" always;
# Vite React Refresh injects an inline preamble in local development.
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors *" always;
proxy_pass http://web-chat:5175;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Local-only, credential-safe demo importer for the FoxRay support portal."""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
SEED_DIR = Path(__file__).resolve().parent
MANIFEST_PATH = SEED_DIR / "support_portal.json"
def require_local_environment() -> None:
if (
os.environ.get("CUS_ENV") != "local"
or os.environ.get("CUSTOCRM_LOCAL_SEED") != "1"
):
raise SystemExit(
"Support portal seed is available only through compose.dev.yaml "
"with CUS_ENV=local."
)
def load_manifest() -> dict:
with MANIFEST_PATH.open(encoding="utf-8") as stream:
manifest = json.load(stream)
if manifest.get("schemaVersion") != 1:
raise SystemExit("Unsupported support portal seed manifest version.")
return manifest
def update_fields(instance, values: dict) -> None:
changed = []
for field, value in values.items():
if getattr(instance, field) != value:
setattr(instance, field, value)
changed.append(field)
if changed:
instance.full_clean()
if any(field.name == "updated_at" for field in instance._meta.fields):
changed.append("updated_at")
instance.save(update_fields=changed)
def run() -> None:
require_local_environment()
sys.path.insert(0, "/app/apps/backend")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings")
import django
django.setup()
from django.db import transaction
from django.utils import timezone
from hub_platform.channels.models import Channel
from hub_platform.identity.models import Department, Organization
from hub_platform.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
IntegrationStatus,
)
from hub_platform.products.models import Product
from hub_platform.support_portals.addressing import hosted_domain
from hub_platform.support_portals.models import (
PortalArticle,
PortalArticleRevision,
PortalCategory,
SupportPortal,
SupportPortalProduct,
)
from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus
manifest = load_manifest()
portal_data = manifest["portal"]
widget_data = manifest["widget"]
now = timezone.now()
with transaction.atomic():
organization = Organization.objects.get(slug=manifest["organization"])
department = Department.objects.get(
organization=organization,
code="support",
)
product = Product.objects.get(
organization=organization,
code=portal_data["product"],
)
channel, _ = Channel.objects.get_or_create(
organization=organization,
code=widget_data["channel"],
defaults={
"name": widget_data["channelName"],
"department": department,
"product": product,
},
)
update_fields(
channel,
{
"name": widget_data["channelName"],
"department": department,
"product": product,
"is_active": True,
"requires_authenticated_product_identity": False,
"allow_anonymous_sessions": True,
"allow_self_reported_contact": True,
"allow_sales_attribution": False,
"allow_checkout_actions": False,
},
)
integration, _ = Integration.objects.get_or_create(
organization=organization,
provider=IntegrationProvider.WEB,
name=widget_data["integrationName"],
defaults={"kind": IntegrationKind.MESSENGER},
)
update_fields(
integration,
{
"kind": IntegrationKind.MESSENGER,
"channel": channel,
"config": widget_data["config"],
"status": IntegrationStatus.OK,
"is_active": True,
"last_error": "",
},
)
portal, _ = SupportPortal.objects.get_or_create(
slug=portal_data["slug"],
defaults={
"organization": organization,
"department": department,
"hosted_domain": hosted_domain(portal_data["slug"]),
"name": portal_data["name"],
"default_locale": portal_data["locale"],
},
)
update_fields(
portal,
{
"department": department,
"hosted_domain": hosted_domain(portal_data["slug"]),
"name": portal_data["name"],
"default_locale": portal_data["locale"],
"widget_channel": channel,
"status": PortalStatus.PUBLISHED,
"published_at": portal.published_at or now,
},
)
authenticated_channel = Channel.objects.filter(
organization=organization,
code=portal_data["authenticatedSupportChannel"],
product=product,
department=department,
requires_authenticated_product_identity=True,
allow_anonymous_sessions=False,
).first()
product_link, _ = SupportPortalProduct.objects.get_or_create(
organization=organization,
portal=portal,
product=product,
defaults={"support_channel": authenticated_channel},
)
if product_link.support_channel_id != getattr(
authenticated_channel,
"id",
None,
):
product_link.support_channel = authenticated_channel
product_link.full_clean()
product_link.save(update_fields=["support_channel"])
categories = {}
for item in manifest["categories"]:
category, _ = PortalCategory.objects.get_or_create(
organization=organization,
portal=portal,
slug=item["slug"],
defaults={"name": item["name"]},
)
update_fields(
category,
{
"name": item["name"],
"description": item["description"],
"sort_order": item["sortOrder"],
},
)
categories[item["slug"]] = category
for item in manifest["articles"]:
article, _ = PortalArticle.objects.get_or_create(
organization=organization,
portal=portal,
locale=portal_data["locale"],
slug=item["slug"],
defaults={"category": categories[item["category"]]},
)
category = categories[item["category"]]
latest = article.revisions.order_by("-revision").first()
revision_content = (
item["title"],
item["summary"],
item["content"],
)
if latest is None or (
latest.title,
latest.summary,
latest.content,
) != revision_content:
latest = PortalArticleRevision.objects.create(
organization=organization,
article=article,
revision=(latest.revision + 1) if latest else 1,
title=item["title"],
summary=item["summary"],
content=item["content"],
published_at=now,
)
elif latest.published_at is None:
latest.published_at = now
latest.save(update_fields=["published_at"])
article.category = category
article.status = ArticleStatus.PUBLISHED
article.published_revision = latest
article.full_clean()
article.save(
update_fields=[
"category",
"status",
"published_revision",
"updated_at",
]
)
print(
json.dumps(
{
"organization": organization.slug,
"portal": portal.slug,
"url": f"http://{portal.hosted_domain}/",
"widgetChannel": channel.code,
"categories": len(categories),
"articles": len(manifest["articles"]),
"idempotent": True,
"credentialsChanged": False,
},
ensure_ascii=False,
)
)
if __name__ == "__main__":
run()
+102
View File
@@ -0,0 +1,102 @@
{
"schemaVersion": 1,
"organization": "edevs",
"portal": {
"slug": "foxray",
"name": "FoxRay",
"locale": "ru",
"product": "foxray",
"authenticatedSupportChannel": "foxray-support"
},
"widget": {
"channel": "foxray-help",
"channelName": "FoxRay — чат поддержки",
"integrationName": "FoxRay Help Center Web",
"config": {
"title": "Поддержка FoxRay",
"accent": "#1677ff",
"greeting": "Здравствуйте! Поможем разобраться с FoxRay.",
"consent_text": "Продолжая, вы соглашаетесь на обработку сообщений для ответа на обращение.",
"consent_version": "v1",
"quick_replies": [
"Как начать работу",
"Не могу войти",
"Связаться со специалистом"
],
"allowed_domains": [
"foxray.localhost",
"localhost"
]
}
},
"categories": [
{
"slug": "getting-started",
"name": "Начало работы",
"description": "Первые шаги и основные возможности",
"sortOrder": 10
},
{
"slug": "account-access",
"name": "Аккаунт и доступ",
"description": "Вход, восстановление доступа и уведомления",
"sortOrder": 20
},
{
"slug": "support",
"name": "Обращения в поддержку",
"description": "Создание и отслеживание обращений",
"sortOrder": 30
},
{
"slug": "billing",
"name": "Оплата и документы",
"description": "Счета и закрывающие документы",
"sortOrder": 40
}
],
"articles": [
{
"slug": "first-login",
"category": "getting-started",
"title": "Первый вход в FoxRay",
"summary": "Краткий маршрут после получения доступа.",
"content": "# Первый вход в FoxRay\n\n1. Откройте адрес рабочего пространства, который прислал администратор.\n2. Введите рабочий email и пароль.\n3. Проверьте название организации в верхней части страницы.\n\n> Если приглашение или адрес не открывается, напишите в чат поддержки на этой странице.\n\n## Что проверить после входа\n\n- доступные разделы;\n- профиль и контактные данные;\n- настройки уведомлений."
},
{
"slug": "restore-access",
"category": "account-access",
"title": "Как восстановить доступ",
"summary": "Что проверить, если не получается войти.",
"content": "# Как восстановить доступ\n\nСначала убедитесь, что используете рабочий email и правильный адрес организации.\n\n## Если войти не удалось\n\n- повторите ввод без пробелов в начале и конце;\n- проверьте раскладку и `Caps Lock`;\n- обратитесь к администратору вашей организации;\n- напишите в чат поддержки и укажите рабочий email.\n\nПароль в сообщении отправлять не нужно."
},
{
"slug": "notifications",
"category": "account-access",
"title": "Настройка уведомлений",
"summary": "Как выбрать важные события и не пропустить ответ.",
"content": "# Настройка уведомлений\n\nОткройте настройки профиля и проверьте доступные способы уведомлений.\n\n| Событие | Рекомендация |\n| --- | --- |\n| Ответ специалиста | Оставить включённым |\n| Изменение статуса | Оставить включённым |\n| Информационные сообщения | Настроить по необходимости |\n\nИзменения применяются после сохранения формы."
},
{
"slug": "create-support-request",
"category": "support",
"title": "Как обратиться в поддержку",
"summary": "Какие данные помогут быстрее разобраться в вопросе.",
"content": "# Как обратиться в поддержку\n\nНажмите синюю кнопку чата в правом нижнем углу портала.\n\nОпишите:\n\n- что вы хотели сделать;\n- что произошло вместо ожидаемого результата;\n- адрес страницы;\n- время возникновения проблемы.\n\nСекреты, пароли и платёжные данные отправлять не нужно."
},
{
"slug": "track-request",
"category": "support",
"title": "Где посмотреть статус обращения",
"summary": "Как продолжить уже начатый разговор.",
"content": "# Где посмотреть статус обращения\n\nВернитесь на портал в том же браузере и снова откройте чат. Активная локальная сессия продолжится автоматически.\n\nСтатусы в чате показывают, кто сейчас готовит ответ: виртуальный помощник или специалист поддержки."
},
{
"slug": "payment-documents",
"category": "billing",
"title": "Оплата и документы",
"summary": "Куда обратиться по вопросам счетов и документов.",
"content": "# Оплата и документы\n\nПо вопросу оплаты или закрывающих документов напишите в чат поддержки.\n\nУкажите:\n\n1. название организации;\n2. номер счёта, если он известен;\n3. период, за который нужны документы.\n\nНе прикладывайте данные банковской карты."
}
]
}
+3
View File
@@ -11,6 +11,9 @@ CUSTOCRM_PLATFORM_DOMAIN=platform.example.com
CUS_HELP_BASE_DOMAIN=help.example.com
CUS_HELP_PUBLIC_SCHEME=https
CUS_HELP_PUBLIC_PORT=
# Необязательный отдельный IPv4 для A-записей собственных доменов.
# По умолчанию используется CUSTOCRM_WEB_LISTENING_IP.
CUS_HELP_PUBLIC_IPV4=203.0.113.10
CUSTOCRM_ACME_EMAIL=admin@example.com
CUSTOCRM_ADMIN_PORT=18001
# IP web-gateway. Оставьте 0.0.0.0 без profile calls. Для calls укажите
+5
View File
@@ -0,0 +1,5 @@
$ErrorActionPreference = "Stop"
# Portal-only demo data: no users, passwords, roles, sessions, or credentials.
docker compose -f compose.yaml -f compose.dev.yaml --profile support-portal-seed `
--env-file .env.example --env-file .env run --rm support-portal-seed
+8 -9
View File
@@ -145,24 +145,23 @@ async function login(page: Page, user: object) {
await page.getByRole("button", { name: "Войти" }).click();
}
test("OWNER logs in and lands on the command center with the global sidebar", async ({ page }) => {
test("OWNER logs in and sees every available sidebar section collapsed", async ({ page }) => {
await login(page, OWNER_IDENTITY);
await expect(page).toHaveURL(new RegExp(`/organizations/${ORGANIZATION_PUBLIC_ID}/`));
await expect(page.getByRole("heading", { name: "Командный центр" })).toBeVisible();
// Глобальный sidebar уровня компании.
await expect(page.getByText("Уровень компании")).toBeVisible();
await expect(page.getByText("Рабочее пространство")).toHaveCount(0);
await expect(page.locator(".hub-nav-section-toggle", { hasText: "Продажи" })).toHaveAttribute("aria-expanded", "false");
await expect(page.locator(".hub-nav-section-toggle", { hasText: "Поддержка" })).toHaveAttribute("aria-expanded", "false");
await expect(page.locator(".hub-nav-section-toggle", { hasText: "AI" })).toHaveAttribute("aria-expanded", "false");
await expect(page.getByRole("button", { name: "Отделы", exact: true })).toHaveCount(0);
});
test("OPERATOR logs in and lands on sales dialogs with the sales sidebar", async ({ page }) => {
test("OPERATOR logs in and sees the accessible department in the main sidebar", async ({ page }) => {
await login(page, OPERATOR_IDENTITY);
await expect(page).toHaveURL(/\/departments\/sales\/dialogs/);
// Операторская навигация показывает только рабочее пространство продаж.
await expect(page.getByText("Рабочее пространство")).toBeVisible();
await expect(page.getByText("Уровень компании")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Назад" })).toHaveCount(0);
await expect(page.locator(".hub-nav-section-toggle", { hasText: "Продажи" })).toHaveAttribute("aria-expanded", "false");
await expect(page.getByRole("button", { name: "Поддержка", exact: true })).toHaveCount(0);
});
test("OPERATOR opening an owner-only route sees the 403 permission screen", async ({ page }) => {