mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1da84c9734 | ||
|
|
82805f7b1a | ||
|
|
eca0e52667 |
No files matched your search
@@ -247,6 +247,7 @@ MESSAGES: dict[str, object] = {
|
||||
"audit.action_integrations_integration_created": "Integration added",
|
||||
"audit.action_integrations_integration_deleted": "Integration deleted",
|
||||
"audit.action_integrations_integration_updated": "Integration changed",
|
||||
"audit.action_organization_created": "Organization created from the interface",
|
||||
"audit.action_organization_owner_activated": "Organization owner activated",
|
||||
"audit.action_organization_owner_invitation_requested": "Owner invitation sent",
|
||||
"audit.action_organization_provisioned": "Organization created",
|
||||
@@ -332,6 +333,7 @@ MESSAGES: dict[str, object] = {
|
||||
"identity.invalid_credentials": "Invalid credentials",
|
||||
"identity.invalid_totp_code": "Invalid TOTP code",
|
||||
"identity.invitation_email_mismatch": "Invitation email does not match the account",
|
||||
"identity.organization_create_forbidden": "Only the installation administrator and organization owners or administrators can create organizations",
|
||||
"identity.invitation_invalid": "Invitation is invalid or has expired",
|
||||
"identity.invitation_account_exists": "An account with this address already exists. Sign in with it",
|
||||
"updates.nothing_to_install": "The latest version is already installed",
|
||||
|
||||
@@ -251,6 +251,7 @@ MESSAGES: dict[str, object] = {
|
||||
"audit.action_integrations_integration_created": "Добавлена интеграция",
|
||||
"audit.action_integrations_integration_deleted": "Удалена интеграция",
|
||||
"audit.action_integrations_integration_updated": "Изменена интеграция",
|
||||
"audit.action_organization_created": "Создана организация из интерфейса",
|
||||
"audit.action_organization_owner_activated": "Активирован владелец организации",
|
||||
"audit.action_organization_owner_invitation_requested": "Отправлено приглашение владельцу",
|
||||
"audit.action_organization_provisioned": "Создана организация",
|
||||
@@ -336,6 +337,7 @@ MESSAGES: dict[str, object] = {
|
||||
"identity.invalid_credentials": "Неверный email или пароль",
|
||||
"identity.invalid_totp_code": "Неверный код",
|
||||
"identity.invitation_email_mismatch": "Приглашение выписано на другой адрес",
|
||||
"identity.organization_create_forbidden": "Создавать организации могут администратор установки и владельцы или администраторы организаций",
|
||||
"identity.invitation_invalid": "Приглашение недействительно или истекло",
|
||||
"identity.invitation_account_exists": "Учётная запись с этим адресом уже есть — войдите под ней",
|
||||
"updates.nothing_to_install": "Установлена последняя версия",
|
||||
|
||||
@@ -35,7 +35,10 @@ class OrganizationSettingsInput:
|
||||
language: str = ""
|
||||
|
||||
|
||||
def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
|
||||
def validate_organization_settings(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
|
||||
"""Имя, часовой пояс, валюта и язык организации — одни правила для
|
||||
«Настроек» и для страницы создания организации."""
|
||||
|
||||
name = data.name.strip()
|
||||
timezone = data.timezone.strip()
|
||||
currency = data.currency.strip().upper()
|
||||
@@ -67,7 +70,7 @@ def update_organization_settings(
|
||||
context: TenantContext,
|
||||
data: OrganizationSettingsInput,
|
||||
) -> Organization:
|
||||
clean = _validate_input(data)
|
||||
clean = validate_organization_settings(data)
|
||||
organization = Organization.objects.select_for_update().get(
|
||||
pk=context.organization_id
|
||||
)
|
||||
|
||||
@@ -111,6 +111,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
|
||||
"administration.instance_updated": "audit.action_administration_instance_updated",
|
||||
# --- Организация ---
|
||||
"organization.provisioned": "audit.action_organization_provisioned",
|
||||
"organization.created": "audit.action_organization_created",
|
||||
"organization.owner_activated": "audit.action_organization_owner_activated",
|
||||
"organization.owner_invitation_requested": "audit.action_organization_owner_invitation_requested",
|
||||
# --- Интеграции и каналы ---
|
||||
|
||||
@@ -17,7 +17,8 @@ from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
|
||||
from chatballs.identity.instance_settings import default_language
|
||||
from chatballs.identity.models import HumanUser, OrganizationMembership
|
||||
from chatballs.identity.sessions import list_user_sessions
|
||||
from chatballs.tenancy.ingress import user_requires_totp
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import membership_routes_for_user, user_requires_totp
|
||||
|
||||
|
||||
class ProfileUpdateView(APIView):
|
||||
@@ -289,10 +290,20 @@ def _request_organization_language(request: Request) -> str:
|
||||
context = getattr(request, "tenant_context", None)
|
||||
if context is not None:
|
||||
return context.organization.language or ""
|
||||
membership = (
|
||||
OrganizationMembership.objects.select_related("organization")
|
||||
.filter(user=request.user, blocked_at__isnull=True)
|
||||
.order_by("created_at", "id")
|
||||
.first()
|
||||
)
|
||||
return membership.organization.language if membership is not None else ""
|
||||
# Членства роли app без контекста не видны: сначала каталог входа, затем
|
||||
# каждое членство читается в контексте своей организации — как в
|
||||
# identity.auth.common._user_payload.
|
||||
oldest: tuple[object, int, str] | None = None
|
||||
for route in membership_routes_for_user(request.user.id):
|
||||
with tenant_atomic(route.organization_id):
|
||||
membership = (
|
||||
OrganizationMembership.objects.select_related("organization")
|
||||
.filter(id=route.resource_id, user=request.user, blocked_at__isnull=True)
|
||||
.first()
|
||||
)
|
||||
if membership is None:
|
||||
continue
|
||||
key = (membership.created_at, membership.id, membership.organization.language or "")
|
||||
if oldest is None or key[:2] < oldest[:2]:
|
||||
oldest = key
|
||||
return oldest[2] if oldest is not None else ""
|
||||
@@ -25,6 +25,7 @@ from chatballs.identity.models import (
|
||||
)
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import invitation_route
|
||||
|
||||
# Приглашение существующего пользователя в организацию: письмо отправляет
|
||||
# воркер по этому событию (identity.event_handlers).
|
||||
@@ -204,14 +205,37 @@ def register_and_accept(*, token: str, full_name: str, password: str) -> Accepte
|
||||
|
||||
|
||||
def pending_invitation_for_token(token: str) -> OrganizationInvitation | None:
|
||||
return _invitation_for_token(token, accepted=False)
|
||||
|
||||
|
||||
def _invitation_for_token(token: str, *, accepted: bool) -> OrganizationInvitation | None:
|
||||
"""Приглашение по токену из письма — без tenant-контекста на входе.
|
||||
|
||||
Ссылка /join приходит до входа в организацию, а таблица приглашений и
|
||||
строка организации роли app без контекста не видны (tenancy/0003, 0033).
|
||||
Организацию находит security-barrier каталог по хэшу токена (tenancy/0035),
|
||||
и приглашение читается уже в её контексте — вместе с организацией, чтобы
|
||||
вызывающий код мог обращаться к ней и после выхода из контекста.
|
||||
"""
|
||||
|
||||
if not token:
|
||||
return None
|
||||
return OrganizationInvitation.objects.filter(
|
||||
token_hash=_token_hash(token),
|
||||
accepted_at__isnull=True,
|
||||
token_hash = _token_hash(token)
|
||||
route = invitation_route(token_hash)
|
||||
if route is None:
|
||||
return None
|
||||
query = OrganizationInvitation.objects.select_related("organization").filter(
|
||||
id=route.resource_id,
|
||||
organization_id=route.organization_id,
|
||||
token_hash=token_hash,
|
||||
revoked_at__isnull=True,
|
||||
expires_at__gt=timezone.now(),
|
||||
).first()
|
||||
)
|
||||
if accepted:
|
||||
query = query.filter(accepted_at__isnull=False)
|
||||
else:
|
||||
query = query.filter(accepted_at__isnull=True, expires_at__gt=timezone.now())
|
||||
with tenant_atomic(route.organization_id):
|
||||
return query.first()
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -314,16 +338,13 @@ def _already_accepted_for(
|
||||
) -> AcceptedInvitation | None:
|
||||
"""Idempotent re-accept: if this token was already accepted by the same user,
|
||||
return the existing result instead of raising (SPEC-HUB-0021 §11/§15)."""
|
||||
invitation = OrganizationInvitation.objects.filter(
|
||||
token_hash=_token_hash(token),
|
||||
accepted_at__isnull=False,
|
||||
revoked_at__isnull=True,
|
||||
).first()
|
||||
invitation = _invitation_for_token(token, accepted=True)
|
||||
if invitation is None:
|
||||
return None
|
||||
membership = OrganizationMembership.objects.filter(
|
||||
user=user, organization=invitation.organization
|
||||
).first()
|
||||
with tenant_atomic(invitation.organization_id):
|
||||
membership = OrganizationMembership.objects.filter(
|
||||
user=user, organization=invitation.organization
|
||||
).first()
|
||||
if membership is None:
|
||||
return None
|
||||
return AcceptedInvitation(
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Создание организации человеком из интерфейса.
|
||||
|
||||
Кнопка «Добавить организацию» в переключателе (дизайн-базлайн v2, A1) ведёт
|
||||
на страницу с полями организации; тот, кто её заполнил, становится владельцем
|
||||
новой организации и сразу в неё переключается. Это второй путь появления
|
||||
организации рядом с платформенным провижинингом (platform.provisioning_service):
|
||||
там оператор заводит организацию для чужого владельца по e-mail, здесь человек
|
||||
заводит её себе.
|
||||
|
||||
Кто может: администратор установки и любой, у кого есть роль владельца или
|
||||
администратора хотя бы в одной организации. Сотрудник, работающий только в
|
||||
чате, чужую установку организациями не засевает.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils.text import slugify
|
||||
|
||||
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.audience import customer_language
|
||||
from chatballs.identity.administration_services import (
|
||||
OrganizationSettingsInput,
|
||||
validate_organization_settings,
|
||||
)
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
OrganizationStatus,
|
||||
)
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import membership_routes_for_user
|
||||
from chatballs.tenancy.lookup import organization_route_by_slug, reserve_organization_id
|
||||
|
||||
MANAGER_ROLES = frozenset({EmployeeRole.OWNER, EmployeeRole.ADMIN})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CreatedOrganization:
|
||||
organization: Organization
|
||||
membership: OrganizationMembership
|
||||
|
||||
|
||||
def can_create_organization(user: HumanUser) -> bool:
|
||||
"""Администратор установки или менеджер (владелец/администратор) где-либо."""
|
||||
|
||||
if not user.is_active:
|
||||
return False
|
||||
if user.is_instance_admin:
|
||||
return True
|
||||
for route in membership_routes_for_user(user.id):
|
||||
with tenant_atomic(route.organization_id):
|
||||
role = (
|
||||
OrganizationMembership.objects.filter(
|
||||
id=route.resource_id, user=user, blocked_at__isnull=True
|
||||
)
|
||||
.values_list("role", flat=True)
|
||||
.first()
|
||||
)
|
||||
if role in MANAGER_ROLES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def unique_organization_slug(name: str) -> str:
|
||||
"""Слаг из имени, уникальный среди организаций установки.
|
||||
|
||||
Проверка идёт через каталог организаций: роль app без контекста строк
|
||||
организаций не видит (tenancy/0033).
|
||||
"""
|
||||
|
||||
base = slugify(name)[:40].strip("-") or "organization"
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while organization_route_by_slug(candidate) is not None:
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def create_organization(
|
||||
*, data: OrganizationSettingsInput, owner: HumanUser
|
||||
) -> CreatedOrganization:
|
||||
"""Создать организацию и сделать человека её владельцем — одной транзакцией.
|
||||
|
||||
Порядок тот же, что у мастера первого запуска (identity.setup): id
|
||||
выделяется заранее, строка вставляется уже в контексте этого id — иначе
|
||||
роль app не увидит собственную вставку (tenancy/0033, политика 0035).
|
||||
"""
|
||||
|
||||
clean = validate_organization_settings(data)
|
||||
with transaction.atomic():
|
||||
organization_id = reserve_organization_id()
|
||||
with tenant_atomic(organization_id):
|
||||
organization = Organization(
|
||||
id=organization_id,
|
||||
name=clean.name,
|
||||
slug=unique_organization_slug(clean.name),
|
||||
status=OrganizationStatus.ACTIVE,
|
||||
timezone=clean.timezone,
|
||||
currency=clean.currency,
|
||||
language=clean.language,
|
||||
)
|
||||
organization.save(force_insert=True)
|
||||
ensure_uncategorized_category(organization)
|
||||
membership = OrganizationMembership.objects.create(
|
||||
user=owner,
|
||||
organization=organization,
|
||||
role=EmployeeRole.OWNER,
|
||||
# Должность — текстом на языке организации, как в провижининге.
|
||||
position_title=t("setup.owner_position", language=customer_language(organization)),
|
||||
totp_required=False,
|
||||
)
|
||||
record_audit_event(
|
||||
action="organization.created",
|
||||
actor=owner,
|
||||
organization=organization,
|
||||
object_type="Organization",
|
||||
object_id=str(organization.public_id),
|
||||
payload={"organizationName": organization.name},
|
||||
)
|
||||
enqueue_event(
|
||||
DomainEvent(
|
||||
aggregate_type="Organization",
|
||||
aggregate_id=str(organization.public_id),
|
||||
event_type="organization.provisioned",
|
||||
payload={},
|
||||
tenant_context=TenantContext.for_resource(
|
||||
organization,
|
||||
actor_kind=TenantActorKind.SYSTEM,
|
||||
actor_user=owner,
|
||||
),
|
||||
)
|
||||
)
|
||||
return CreatedOrganization(organization=organization, membership=membership)
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from chatballs.identity import organization_views
|
||||
|
||||
urlpatterns = [
|
||||
path("", organization_views.OrganizationCreateView.as_view(), name="organization-create"),
|
||||
path(
|
||||
"options/",
|
||||
organization_views.OrganizationCreateOptionsView.as_view(),
|
||||
name="organization-create-options",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Создание организации из интерфейса: /api/v1/organizations/ без uuid в адресе.
|
||||
|
||||
Организации ещё нет, поэтому tenant middleware этот путь не трогает: контекст
|
||||
открывает сам сервис вокруг вставки. Ответ повторяет форму ответа приглашения
|
||||
(auth.invitations): обновлённая учётная запись со списком членств и публичный
|
||||
id организации, в которую интерфейсу переключиться.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.administration_payloads import (
|
||||
administration_languages,
|
||||
administration_timezones,
|
||||
)
|
||||
from chatballs.identity.administration_services import OrganizationSettingsInput
|
||||
from chatballs.identity.auth.common import _user_payload, validation_response
|
||||
from chatballs.identity.organization_creation import (
|
||||
can_create_organization,
|
||||
create_organization,
|
||||
)
|
||||
|
||||
|
||||
def _forbidden() -> Response:
|
||||
return Response({"detail": t("identity.organization_create_forbidden")}, status=403)
|
||||
|
||||
|
||||
class OrganizationCreateOptionsView(APIView):
|
||||
"""Справочники для формы: часовые пояса и языки, как в «Настройках»."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
if not can_create_organization(request.user):
|
||||
return _forbidden()
|
||||
return Response(
|
||||
{
|
||||
"timezones": administration_timezones(),
|
||||
"languages": administration_languages(),
|
||||
"currencies": ["RUB"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class OrganizationCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
if not can_create_organization(request.user):
|
||||
return _forbidden()
|
||||
body = request.data if isinstance(request.data, dict) else {}
|
||||
try:
|
||||
created = create_organization(
|
||||
data=OrganizationSettingsInput(
|
||||
name=str(body.get("name", "")),
|
||||
timezone=str(body.get("timezone", "") or "Europe/Moscow"),
|
||||
currency=str(body.get("currency", "") or "RUB"),
|
||||
language=str(body.get("language", "")),
|
||||
),
|
||||
owner=request.user,
|
||||
)
|
||||
except ValidationError as error:
|
||||
return validation_response(error)
|
||||
return Response(
|
||||
{
|
||||
"user": _user_payload(request.user),
|
||||
"organizationPublicId": str(created.organization.public_id),
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Создание организации из интерфейса: кнопка «Добавить организацию» (A1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from chatballs.ai.models import KnowledgeCategory
|
||||
from chatballs.identity.models import (
|
||||
AuditEvent,
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient
|
||||
|
||||
URL = "/api/v1/organizations/"
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF="chatballs_backend.urls_app")
|
||||
class OrganizationCreationTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.first = Organization.objects.create(name="Ателье Норд", slug="atelie-nord")
|
||||
self.owner = HumanUser.objects.create_user(
|
||||
email="owner@example.test", password="Owner-pass-123!", full_name="Елена"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
organization=self.first,
|
||||
user=self.owner,
|
||||
role=EmployeeRole.OWNER,
|
||||
position_title="Владелец",
|
||||
)
|
||||
self.employee = HumanUser.objects.create_user(
|
||||
email="employee@example.test", password="Emp-pass-1234!", full_name="Иван"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
organization=self.first,
|
||||
user=self.employee,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
position_title="Оператор",
|
||||
)
|
||||
|
||||
def _post(self, user: HumanUser, **body):
|
||||
client = TenantAPIClient()
|
||||
client.force_login(user)
|
||||
return client.post(
|
||||
URL,
|
||||
data=json.dumps({"name": "Вторая компания", "timezone": "Europe/Moscow", **body}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_owner_creates_organization_and_becomes_its_owner(self) -> None:
|
||||
response = self._post(self.owner, language="en")
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
payload = response.json()
|
||||
created = Organization.objects.get(public_id=payload["organizationPublicId"])
|
||||
self.assertEqual(created.name, "Вторая компания")
|
||||
self.assertEqual(created.language, "en")
|
||||
self.assertEqual(created.status, "ACTIVE")
|
||||
membership = OrganizationMembership.objects.get(organization=created, user=self.owner)
|
||||
self.assertEqual(membership.role, EmployeeRole.OWNER)
|
||||
self.assertTrue(KnowledgeCategory.objects.filter(organization=created).exists())
|
||||
# Список членств в ответе уже содержит новую организацию: интерфейсу
|
||||
# есть куда переключиться без повторного запроса сессии.
|
||||
self.assertEqual(
|
||||
{item["organizationPublicId"] for item in payload["user"]["memberships"]},
|
||||
{str(self.first.public_id), str(created.public_id)},
|
||||
)
|
||||
self.assertTrue(
|
||||
AuditEvent.objects.filter(
|
||||
organization=created, action="organization.created", actor=self.owner
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_same_name_gets_a_distinct_slug(self) -> None:
|
||||
first = self._post(self.owner).json()["organizationPublicId"]
|
||||
second = self._post(self.owner).json()["organizationPublicId"]
|
||||
|
||||
slugs = set(Organization.objects.filter(public_id__in=[first, second]).values_list("slug", flat=True))
|
||||
self.assertEqual(len(slugs), 2)
|
||||
|
||||
def test_employee_cannot_create_organizations(self) -> None:
|
||||
response = self._post(self.employee)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(Organization.objects.count(), 1)
|
||||
|
||||
def test_instance_admin_without_memberships_can_create(self) -> None:
|
||||
admin = HumanUser.objects.create_user(
|
||||
email="admin@example.test", password="Admin-pass-123!", is_instance_admin=True
|
||||
)
|
||||
|
||||
response = self._post(admin)
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
created = Organization.objects.get(public_id=response.json()["organizationPublicId"])
|
||||
self.assertTrue(OrganizationMembership.objects.filter(organization=created, user=admin, role=EmployeeRole.OWNER).exists())
|
||||
|
||||
def test_empty_name_is_a_field_error(self) -> None:
|
||||
response = self._post(self.owner, name=" ")
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("name", response.json()["errors"])
|
||||
self.assertEqual(Organization.objects.count(), 1)
|
||||
|
||||
def test_anonymous_is_rejected(self) -> None:
|
||||
response = TenantAPIClient().post(URL, data=json.dumps({"name": "X"}), content_type="application/json")
|
||||
|
||||
self.assertIn(response.status_code, {401, 403})
|
||||
|
||||
def test_options_list_timezones_and_languages(self) -> None:
|
||||
client = TenantAPIClient()
|
||||
client.force_login(self.owner)
|
||||
|
||||
response = client.get(f"{URL}options/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Europe/Moscow", response.json()["timezones"])
|
||||
self.assertTrue(response.json()["languages"])
|
||||
@@ -50,6 +50,8 @@ COVERAGE_EXEMPT = {
|
||||
# Настройки установки (адрес, по которому её открывают) — тоже одна
|
||||
# строка на инстанс: их пишет мастер первого запуска, а не демо.
|
||||
("identity", "instancesettings"),
|
||||
# Состояние обновлений установки — одна строка на инстанс (updates/0001).
|
||||
("updates", "updatestate"),
|
||||
}
|
||||
COVERAGE_EXEMPT_APPS = {"platform", "events"}
|
||||
|
||||
|
||||
@@ -64,6 +64,16 @@ def call_invite_route(token_hash: str) -> IngressRoute | None:
|
||||
return _unique_route("call_invite_directory", token_hash)
|
||||
|
||||
|
||||
def invitation_route(token_hash: str) -> IngressRoute | None:
|
||||
"""Приглашение в организацию по хэшу токена из письма (/join).
|
||||
|
||||
Ссылка открывается без контекста — токен и есть единственный ключ. Каталог
|
||||
(tenancy/0035) отдаёт организацию, а само приглашение читается уже в ней.
|
||||
"""
|
||||
|
||||
return _unique_route("invitation_directory", token_hash)
|
||||
|
||||
|
||||
def call_session_route(call_session_id: str) -> IngressRoute | None:
|
||||
return _unique_route("call_session_directory", call_session_id)
|
||||
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Две дыры мультиорганизационности, обе на роли app.
|
||||
#
|
||||
# 1. Организации создаёт человек из интерфейса (кнопка «Добавить организацию»
|
||||
# в переключателе, дизайн-базлайн v2, A1), а не только оператор платформы.
|
||||
# Политика bootstrap из 0032 пускала INSERT роли app лишь до первой
|
||||
# организации. Теперь строка вставляется в контексте своего же id: сервис
|
||||
# заранее берёт id из последовательности (tenancy.lookup.reserve_organization_id),
|
||||
# открывает tenant_atomic(id) и уже в нём пишет строку — ровно так, как
|
||||
# это делал мастер первого запуска. Без контекста INSERT по-прежнему закрыт.
|
||||
#
|
||||
# 2. Ссылка-приглашение /join открывается без tenant-контекста: токен из
|
||||
# письма — единственное, что есть. Таблица приглашений под RLS, и роль app
|
||||
# без контекста не находила приглашение вовсе. Security-barrier каталог
|
||||
# invitation_directory отдаёт по хэшу токена id организации и приглашения —
|
||||
# по образцу call_invite_directory (0004).
|
||||
from django.db import migrations
|
||||
|
||||
BOOTSTRAP_POLICY = "chatballs_organization_app_bootstrap"
|
||||
CREATE_POLICY = "chatballs_organization_app_create"
|
||||
|
||||
FORWARD_SQL = f"""
|
||||
DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization;
|
||||
DROP POLICY IF EXISTS {CREATE_POLICY} ON identity_organization;
|
||||
CREATE POLICY {CREATE_POLICY} ON identity_organization
|
||||
FOR INSERT TO chatballs_runtime_app
|
||||
WITH CHECK (id = chatballs.current_organization_id());
|
||||
|
||||
CREATE OR REPLACE VIEW chatballs.invitation_directory
|
||||
WITH (security_barrier = true) AS
|
||||
SELECT invitation.id AS resource_id,
|
||||
invitation.organization_id,
|
||||
invitation.token_hash AS lookup_key
|
||||
FROM identity_organizationinvitation invitation;
|
||||
ALTER VIEW chatballs.invitation_directory OWNER TO chatballs_schema;
|
||||
REVOKE ALL ON chatballs.invitation_directory FROM PUBLIC;
|
||||
GRANT SELECT ON chatballs.invitation_directory
|
||||
TO chatballs_runtime_app, chatballs_runtime_platform;
|
||||
"""
|
||||
|
||||
REVERSE_SQL = f"""
|
||||
DROP VIEW IF EXISTS chatballs.invitation_directory;
|
||||
DROP POLICY IF EXISTS {CREATE_POLICY} ON identity_organization;
|
||||
DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization;
|
||||
CREATE POLICY {BOOTSTRAP_POLICY} ON identity_organization
|
||||
FOR INSERT TO chatballs_runtime_app
|
||||
WITH CHECK (NOT chatballs.instance_has_organizations());
|
||||
"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tenancy", "0034_update_state_grants"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)]
|
||||
@@ -1,11 +1,20 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import DatabaseError, connection, transaction
|
||||
from django.test import TransactionTestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
|
||||
from chatballs.ai.models import AIAgent, Knowledge
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.group_models import EmployeeGroup
|
||||
from chatballs.identity.invitation_models import OrganizationInvitation
|
||||
from chatballs.identity.invitation_service import (
|
||||
invitation_preview,
|
||||
issue_invitation,
|
||||
pending_invitation_for_token,
|
||||
)
|
||||
from chatballs.identity.models import (
|
||||
AuditEvent,
|
||||
AuditResult,
|
||||
@@ -14,7 +23,8 @@ from chatballs.identity.models import (
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.tenancy.database import current_tenant_id, set_local_tenant
|
||||
from chatballs.tenancy.database import current_tenant_id, set_local_tenant, tenant_atomic
|
||||
from chatballs.tenancy.lookup import reserve_organization_id
|
||||
from chatballs.tenancy.models import StorageReservation
|
||||
from chatballs.testing import TenantAPIClient
|
||||
|
||||
@@ -429,7 +439,7 @@ class RowLevelSecurityTests(TransactionTestCase):
|
||||
|
||||
|
||||
|
||||
def test_app_role_reads_ingress_directory_and_cannot_add_organizations(self) -> None:
|
||||
def test_app_role_reads_ingress_directory_and_adds_organizations_only_in_context(self) -> None:
|
||||
# Каталоги входа доступны роли app (tenancy/0032): backend-app
|
||||
# обходится без platform-соединения.
|
||||
with transaction.atomic():
|
||||
@@ -441,10 +451,45 @@ class RowLevelSecurityTests(TransactionTestCase):
|
||||
[self.user.id],
|
||||
)
|
||||
self.assertEqual(cursor.fetchone()[0], self.first.id)
|
||||
# Организации уже есть — INSERT для app закрыт политикой bootstrap.
|
||||
# Без контекста INSERT организации для app закрыт (tenancy/0035).
|
||||
with self.assertRaises(DatabaseError), transaction.atomic():
|
||||
self._set_role("chatballs_runtime_app")
|
||||
Organization.objects.create(name="Third", slug="rls-third")
|
||||
# В контексте заранее выделенного id — открыт: так работает кнопка
|
||||
# «Добавить организацию» (identity.organization_creation).
|
||||
with transaction.atomic():
|
||||
self._set_role("chatballs_runtime_app")
|
||||
organization_id = reserve_organization_id()
|
||||
with tenant_atomic(organization_id):
|
||||
Organization(id=organization_id, name="Third", slug="rls-third").save(force_insert=True)
|
||||
self.assertEqual(Organization.objects.get(pk=organization_id).slug, "rls-third")
|
||||
self.assertTrue(Organization.objects.filter(slug="rls-third").exists())
|
||||
# Чужой контекст не подходит: id строки обязан совпасть с контекстом.
|
||||
with self.assertRaises(DatabaseError), transaction.atomic():
|
||||
self._set_role("chatballs_runtime_app")
|
||||
with tenant_atomic(self.first.id):
|
||||
Organization(id=reserve_organization_id(), name="Fourth", slug="rls-fourth").save(force_insert=True)
|
||||
|
||||
def test_app_role_finds_invitation_by_token_without_context(self) -> None:
|
||||
# Ссылка /join открывается без контекста: приглашение находит каталог
|
||||
# invitation_directory (tenancy/0035), иначе роль app видела бы пустоту.
|
||||
issued = issue_invitation(
|
||||
organization=self.second,
|
||||
email="invited@example.test",
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
expires_at=timezone.now() + timedelta(days=1),
|
||||
created_by=None,
|
||||
)
|
||||
with transaction.atomic():
|
||||
self._set_role("chatballs_runtime_app")
|
||||
self.assertEqual(OrganizationInvitation.objects.count(), 0)
|
||||
invitation = pending_invitation_for_token(issued.token)
|
||||
self.assertIsNotNone(invitation)
|
||||
self.assertEqual(invitation.id, issued.invitation.id)
|
||||
self.assertEqual(invitation.organization.slug, "rls-second")
|
||||
self.assertIsNone(pending_invitation_for_token("wrong-token"))
|
||||
preview = invitation_preview(issued.token)
|
||||
self.assertEqual(preview["organizationName"], "Second")
|
||||
|
||||
def test_platform_role_can_only_use_ingress_directory(self) -> None:
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ urlpatterns = [
|
||||
path("api/v1/auth/", include("chatballs.identity.auth_urls")),
|
||||
path("api/v1/setup/", include("chatballs.identity.setup_urls")),
|
||||
path("api/v1/instance/", include("chatballs.identity.instance_urls")),
|
||||
# Создание организации из интерфейса: адрес без uuid, контекст открывает
|
||||
# сам сервис. Маршруты с uuid ниже его не перехватывают.
|
||||
path("api/v1/organizations/", include("chatballs.identity.organization_urls")),
|
||||
path(
|
||||
"api/v1/demo-media/avatars/<str:name>",
|
||||
DemoMediaView.as_view(),
|
||||
|
||||
@@ -12,7 +12,7 @@ import { api, setActiveOrganization } from "./api/client";
|
||||
import { fetchAgentDirectory } from "./features/agents/model";
|
||||
import { canAccess, defaultRoute, isManager } from "./auth/access";
|
||||
import { activateOrganization, clearOrganizationPreference } from "./auth/session";
|
||||
import { AuthChangePassword, AuthJoin, AuthJoinGuest, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
|
||||
import { AuthChangePassword, AuthChooseOrganization, AuthJoin, AuthJoinGuest, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
|
||||
import { Shell } from "./layout/Shell";
|
||||
import { pathFromRoute, routeFromPath } from "./router";
|
||||
import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui";
|
||||
@@ -40,6 +40,9 @@ export function App() {
|
||||
const [user, setUser] = useState<SessionUser | null>(null);
|
||||
const [organizationPublicId, setOrganizationPublicId] = useState<string | null>(initialRoute.organizationPublicId);
|
||||
const [totpChallenge, setTotpChallenge] = useState<AuthChallenge | null>(null);
|
||||
// Учётная запись с несколькими организациями после входа выбирает, с какой
|
||||
// начать. Ссылка на конкретную организацию экран выбора минует.
|
||||
const [organizationChoice, setOrganizationChoice] = useState<AuthenticatedUser | null>(null);
|
||||
const [recovering, setRecovering] = useState(false);
|
||||
const [resetting, setResetting] = useState(() => window.location.pathname === "/reset-password");
|
||||
// Ссылка из письма-приглашения (/join?token=…): токен запоминается до входа
|
||||
@@ -132,6 +135,11 @@ export function App() {
|
||||
}, [loadData, user]);
|
||||
|
||||
const landAfterAuth = useCallback((nextIdentity: AuthenticatedUser) => {
|
||||
if (!initialRoute.organizationPublicId && nextIdentity.memberships.length > 1) {
|
||||
acceptServerLanguage(nextIdentity.language);
|
||||
setOrganizationChoice(nextIdentity);
|
||||
return;
|
||||
}
|
||||
const activeUser = useIdentity(nextIdentity, initialRoute.organizationPublicId);
|
||||
if (activeUser) {
|
||||
navigate(defaultRoute(activeUser), null, true, activeUser.organizationPublicId);
|
||||
@@ -163,6 +171,24 @@ export function App() {
|
||||
|
||||
const joinUseLogin = useCallback(() => setJoinNeedsLogin(true), []);
|
||||
|
||||
const chooseOrganization = useCallback((organizationId: string) => {
|
||||
if (!organizationChoice) return;
|
||||
setOrganizationChoice(null);
|
||||
const activeUser = useIdentity(organizationChoice, organizationId);
|
||||
if (activeUser) {
|
||||
navigate(defaultRoute(activeUser), null, true, activeUser.organizationPublicId);
|
||||
}
|
||||
}, [navigate, organizationChoice, useIdentity]);
|
||||
|
||||
// Новая организация создана: сервер вернул учётную запись с обновлённым
|
||||
// списком членств — переключаемся в неё сразу, как после приглашения.
|
||||
const finishOrganizationCreate = useCallback((nextIdentity: AuthenticatedUser, organizationId: string) => {
|
||||
const activeUser = useIdentity(nextIdentity, organizationId);
|
||||
if (activeUser) {
|
||||
navigate(defaultRoute(activeUser), null, false, activeUser.organizationPublicId);
|
||||
}
|
||||
}, [navigate, useIdentity]);
|
||||
|
||||
const cancelJoin = useCallback(() => {
|
||||
setJoinToken(null);
|
||||
if (user) navigate(defaultRoute(user), null, true, user.organizationPublicId);
|
||||
@@ -170,6 +196,7 @@ export function App() {
|
||||
|
||||
async function logout() {
|
||||
await api("/api/v1/auth/logout/", { method: "POST" }).catch(() => undefined);
|
||||
setOrganizationChoice(null);
|
||||
setIdentity(null);
|
||||
setUser(null);
|
||||
setOrganizationPublicId(null);
|
||||
@@ -194,6 +221,8 @@ export function App() {
|
||||
<ConfigProvider theme={antdTheme} locale={antdLocale}>
|
||||
{totpChallenge ? (
|
||||
<AuthTotpCode challenge={totpChallenge} onVerified={(nextUser) => { setTotpChallenge(null); landAfterAuth(nextUser); }} />
|
||||
) : organizationChoice ? (
|
||||
<AuthChooseOrganization identity={organizationChoice} onChoose={chooseOrganization} onLogout={logout} />
|
||||
) : !identity ? (
|
||||
joinToken && !joinNeedsLogin && !needsSetup ? (
|
||||
<AuthJoinGuest token={joinToken} onUseLogin={joinUseLogin} onRegistered={finishJoin} />
|
||||
@@ -217,7 +246,7 @@ export function App() {
|
||||
) : !canAccess(user, navigation.route) ? (
|
||||
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
|
||||
) : (
|
||||
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} />
|
||||
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} onOrganizationCreated={finishOrganizationCreate} />
|
||||
)}
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -17,10 +17,18 @@ export function hasCapability(user: SessionUser, capability: string): boolean {
|
||||
return user.capabilities.includes(capability);
|
||||
}
|
||||
|
||||
/** Кто заводит новые организации: администратор установки и тот, кто где-либо
|
||||
* владелец или администратор. Роль в текущей организации не решает —
|
||||
* сотрудник здесь может быть владельцем в другой. Сервер проверяет то же. */
|
||||
export function canCreateOrganization(user: SessionUser): boolean {
|
||||
return user.isInstanceAdmin || user.memberships.some((membership) => membership.role === "OWNER" || membership.role === "ADMIN");
|
||||
}
|
||||
|
||||
export function canAccess(user: SessionUser, route: RouteKey): boolean {
|
||||
// «Настройки» — настройки организации: только владелец и админ. Личные
|
||||
// параметры сотрудника живут на странице «Профиль» (дизайн-базлайн v2).
|
||||
if (route === "profile") return true;
|
||||
if (route === "organizationCreate") return canCreateOrganization(user);
|
||||
if (isManager(user)) return true;
|
||||
return EMPLOYEE_ROUTES.has(route);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LANGUAGES } from "@chatballs/shared";
|
||||
|
||||
import { api, ApiError } from "../../api/client";
|
||||
import { ChannelGlyph } from "../../shared/badges";
|
||||
import { SelectField } from "../../shared/form-controls";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { EmptyState, ErrorScreen, LoadingState } from "../../shared/ui";
|
||||
import { BackLink, Button, CopyButton } from "../../shared/ui-controls";
|
||||
@@ -509,25 +510,16 @@ function AssignmentCard({ card, groups, canManage, busy, apply }: {
|
||||
? <input value={name} onChange={(event) => setName(event.target.value)} onBlur={() => { if (dirty) void apply({ name: name.trim() }); }} />
|
||||
: <span className="agent-field-static">{card.name}</span>}
|
||||
</label>
|
||||
<label className="agent-field is-select">
|
||||
<span>{t("common.group")}</span>
|
||||
<span className="agent-field-control">
|
||||
<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />
|
||||
{canManage ? (
|
||||
<select
|
||||
disabled={busy}
|
||||
value={card.groupId === null ? "" : String(card.groupId)}
|
||||
onChange={(event) => void apply({ groupId: event.target.value ? Number(event.target.value) : null })}
|
||||
>
|
||||
<option value="">{t("common.no_group")}</option>
|
||||
{groups.map((group) => <option value={String(group.id)} key={group.id}>{group.name}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="agent-field-static">{card.groupName ?? t("common.no_group")}</span>
|
||||
)}
|
||||
<Icon name="chevron" size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
</label>
|
||||
<SelectField
|
||||
adornment={<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />}
|
||||
disabled={busy}
|
||||
label={t("common.group")}
|
||||
readOnly={!canManage}
|
||||
readOnlyText={card.groupName ?? t("common.no_group")}
|
||||
value={card.groupId === null ? "" : String(card.groupId)}
|
||||
onChange={(next) => void apply({ groupId: next ? Number(next) : null })}
|
||||
options={[["", t("common.no_group")], ...groups.map((group) => [String(group.id), group.name] as [string, string])]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -558,24 +550,16 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
|
||||
<h3>{t("common.model")}</h3>
|
||||
<p>{t("ai.provider_key_lives_under_settings")}</p>
|
||||
<div className="agent-side-fields">
|
||||
<label className={`agent-field is-select ${missingProvider ? "is-invalid" : ""}`}>
|
||||
<span>{t("ai.provider")}</span>
|
||||
<span className="agent-field-control">
|
||||
{canManage ? (
|
||||
<select
|
||||
disabled={busy}
|
||||
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
|
||||
onChange={(event) => void apply({ providerIntegrationId: event.target.value ? Number(event.target.value) : null })}
|
||||
>
|
||||
<option value="">{t("ai.not_selected")}</option>
|
||||
{providers.map((item) => <option value={String(item.id)} key={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="agent-field-static">{providerName || t("ai.not_selected")}</span>
|
||||
)}
|
||||
<Icon name="chevron" size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
</label>
|
||||
<SelectField
|
||||
disabled={busy}
|
||||
invalid={missingProvider}
|
||||
label={t("ai.provider")}
|
||||
readOnly={!canManage}
|
||||
readOnlyText={providerName || t("ai.not_selected")}
|
||||
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
|
||||
onChange={(next) => void apply({ providerIntegrationId: next ? Number(next) : null })}
|
||||
options={[["", t("ai.not_selected")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
|
||||
/>
|
||||
<label className="agent-field is-model">
|
||||
<span>{t("common.model")}</span>
|
||||
<span className="agent-field-control">
|
||||
|
||||
@@ -352,23 +352,6 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.agent-create-modal .readonly-field span,
|
||||
.agent-knowledge-modal .readonly-field span {
|
||||
margin-bottom: 6px;
|
||||
color: var(--n-3);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.agent-create-modal .readonly-field input,
|
||||
.agent-create-modal .select-like select {
|
||||
height: 36px;
|
||||
background: var(--n-10);
|
||||
font-size: 13.5px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.agent-create-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -800,7 +783,6 @@
|
||||
|
||||
.agent-field textarea,
|
||||
.agent-field input,
|
||||
.agent-field select,
|
||||
.agent-field .agent-field-static {
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
@@ -814,7 +796,6 @@
|
||||
}
|
||||
|
||||
.agent-field input,
|
||||
.agent-field select,
|
||||
.agent-field > .agent-field-static {
|
||||
height: 36px;
|
||||
line-height: 34px;
|
||||
@@ -859,7 +840,6 @@
|
||||
/* Фокус поля — как в кадре G2: акцентная рамка, кольцо 3px и подложка карточки. */
|
||||
.agent-field textarea:focus,
|
||||
.agent-field input:focus,
|
||||
.agent-field select:focus,
|
||||
.agent-create-modal .form-field.is-editable input:focus,
|
||||
.agent-create-modal .select-like select:focus {
|
||||
border-color: var(--primary);
|
||||
@@ -867,7 +847,9 @@
|
||||
box-shadow: 0 0 0 3px var(--primary-bg);
|
||||
}
|
||||
|
||||
.agent-field-control:focus-within {
|
||||
/* Тот же фокус для общего селекта карточки: с боксом (группа) и без (провайдер). */
|
||||
.agent-side-fields .select-like select:focus,
|
||||
.agent-side-fields .select-box:focus-within {
|
||||
border-color: var(--primary);
|
||||
background: var(--surface-card);
|
||||
box-shadow: 0 0 0 3px var(--primary-bg);
|
||||
@@ -1028,23 +1010,6 @@
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.agent-field.is-invalid .agent-field-control {
|
||||
border-color: var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.agent-field-control select {
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 13.5px;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agent-field-control .agent-field-static {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { roleLabel } from "../../shared/ui";
|
||||
import { AuthFrame } from "./AuthFrame";
|
||||
import { t } from "../../i18n";
|
||||
import type { AuthenticatedUser } from "../../types";
|
||||
|
||||
// Выбор организации после входа — для тех, кто состоит в нескольких. Экран
|
||||
// в той же матовой рамке, что вход и приглашение: список организаций
|
||||
// строками «логотип · название · роль». Ссылка на конкретную организацию
|
||||
// этот экран минует: адрес уже сказал, куда идти.
|
||||
|
||||
function initials(name: string): string {
|
||||
return name.trim().split(/\s+/).map((part) => part[0] ?? "").join("").slice(0, 2).toUpperCase() || "CB";
|
||||
}
|
||||
|
||||
export function AuthChooseOrganization({ identity, onChoose, onLogout }: {
|
||||
identity: AuthenticatedUser;
|
||||
onChoose: (organizationPublicId: string) => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const logoutLink = (
|
||||
<button type="button" className="auth-back-login" onClick={onLogout}><Icon name="logout" size={14} />{t("auth.choose_organization_logout")}</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthFrame
|
||||
title={t("auth.choose_organization_title")}
|
||||
subtitle={t("auth.choose_organization_subtitle", { name: identity.fullName || identity.email })}
|
||||
logo="pulse"
|
||||
width={420}
|
||||
note={logoutLink}
|
||||
>
|
||||
<ul className="auth-card auth-org-list">
|
||||
{identity.memberships.map((membership) => (
|
||||
<li key={membership.organizationPublicId}>
|
||||
<button
|
||||
type="button"
|
||||
className="auth-org-item"
|
||||
onClick={() => onChoose(membership.organizationPublicId)}
|
||||
>
|
||||
<span className={`auth-org-mark ${membership.organizationLogoUrl ? "has-logo" : ""}`}>
|
||||
{membership.organizationLogoUrl
|
||||
? <img src={membership.organizationLogoUrl} alt="" />
|
||||
: initials(membership.organizationName)}
|
||||
</span>
|
||||
<span className="auth-org-copy">
|
||||
<strong>{membership.organizationName}</strong>
|
||||
<small>{membership.positionTitle || roleLabel(membership.role)}</small>
|
||||
</span>
|
||||
<Icon name="chevronRight" size={16} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AuthFrame>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { AuthChangePassword } from "./AuthChangePassword";
|
||||
export { AuthChooseOrganization } from "./AuthChooseOrganization";
|
||||
export { AuthJoin } from "./AuthJoin";
|
||||
export { AuthJoinGuest } from "./AuthJoinGuest";
|
||||
export { AuthLogin } from "./AuthLogin";
|
||||
|
||||
@@ -603,6 +603,102 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Выбор организации после входа: строки «логотип · название · роль» в той же
|
||||
матовой рамке, что и вход. Плитка логотипа — как знак в сайдбаре (28px),
|
||||
только крупнее; строка целиком — кнопка. */
|
||||
.auth-org-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.auth-org-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px 10px 10px;
|
||||
color: var(--n-1);
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--n-8);
|
||||
border-radius: 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.auth-org-item:hover,
|
||||
.auth-org-item:focus-visible {
|
||||
background: var(--primary-bg);
|
||||
border-color: var(--primary-border);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.auth-org-item > svg {
|
||||
flex: none;
|
||||
color: var(--n-5);
|
||||
}
|
||||
|
||||
.auth-org-item:hover > svg {
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
.auth-org-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
color: var(--primary-text);
|
||||
background: var(--primary-bg);
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.auth-org-mark.has-logo {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--n-7);
|
||||
}
|
||||
|
||||
.auth-org-mark img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.auth-org-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.auth-org-copy strong {
|
||||
overflow: hidden;
|
||||
color: var(--n-1);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-org-copy small {
|
||||
overflow: hidden;
|
||||
color: var(--n-4);
|
||||
font-size: 12.5px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-back-login {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { ApiError } from "../../api/client";
|
||||
import { OrganizationLogoField } from "../administration/OrganizationLogoField";
|
||||
import { FormField, SelectField } from "../../shared/form-controls";
|
||||
import { BackLink, Button } from "../../shared/ui-controls";
|
||||
import { timezoneLabel } from "../../shared/utils";
|
||||
import { t } from "../../i18n";
|
||||
import type { AuthenticatedUser, SessionUser } from "../../types";
|
||||
import {
|
||||
createOrganization,
|
||||
loadOrganizationCreateOptions,
|
||||
uploadNewOrganizationLogo,
|
||||
type OrganizationCreateOptions,
|
||||
type OrganizationDraft,
|
||||
} from "./api";
|
||||
|
||||
// Страница создания организации (переключатель A1 → «Добавить организацию»).
|
||||
// Те же поля, что в «Настройках → Организация»: имя, часовой пояс, валюта,
|
||||
// язык, логотип. Создавший становится владельцем и сразу переключается в
|
||||
// новую организацию — ответ сервера уже содержит обновлённый список членств.
|
||||
|
||||
type FieldErrors = Partial<Record<keyof OrganizationDraft, string>>;
|
||||
|
||||
export function OrganizationCreatePage({ user, onCreated, onBack }: {
|
||||
user: SessionUser;
|
||||
onCreated: (identity: AuthenticatedUser, organizationPublicId: string) => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [options, setOptions] = useState<OrganizationCreateOptions | null>(null);
|
||||
const [draft, setDraft] = useState<OrganizationDraft>({
|
||||
name: "",
|
||||
// Новая организация наследует региональные параметры текущей: чаще всего
|
||||
// человек заводит вторую компанию там же, где первую.
|
||||
timezone: "Europe/Moscow",
|
||||
currency: "RUB",
|
||||
language: "",
|
||||
});
|
||||
const [logo, setLogo] = useState<File | null>(null);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
loadOrganizationCreateOptions()
|
||||
.then((payload) => { if (active) setOptions(payload); })
|
||||
.catch((requestError) => {
|
||||
if (active) setError(requestError instanceof ApiError ? requestError.message : t("common.request_failed"));
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
const logoPreview = useMemo(() => (logo ? URL.createObjectURL(logo) : null), [logo]);
|
||||
useEffect(() => () => { if (logoPreview) URL.revokeObjectURL(logoPreview); }, [logoPreview]);
|
||||
|
||||
const valid = draft.name.trim() !== "" && !submitting;
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!valid) return;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
setErrors({});
|
||||
let created;
|
||||
try {
|
||||
created = await createOrganization(draft);
|
||||
} catch (requestError) {
|
||||
if (requestError instanceof ApiError) {
|
||||
const fieldErrors = (requestError.payload as { errors?: FieldErrors }).errors;
|
||||
if (fieldErrors) setErrors(fieldErrors);
|
||||
setError(requestError.message);
|
||||
} else {
|
||||
setError(t("organizations.create_failed"));
|
||||
}
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (logo) {
|
||||
// Логотип — уже в созданную организацию. Его неудача организацию не
|
||||
// отменяет: человек попадает в неё и доложит логотип в «Настройках».
|
||||
try {
|
||||
await uploadNewOrganizationLogo(created.organizationPublicId, logo);
|
||||
} catch {
|
||||
window.setTimeout(() => window.alert(t("organizations.logo_failed")), 0);
|
||||
}
|
||||
}
|
||||
onCreated(created.user, created.organizationPublicId);
|
||||
}
|
||||
|
||||
const timezones = options?.timezones ?? [draft.timezone];
|
||||
const languages = options?.languages ?? [];
|
||||
|
||||
return (
|
||||
<div className="organization-create">
|
||||
<BackLink label={user.organizationName || "Chatballs"} onClick={onBack} />
|
||||
<header className="organization-create-head">
|
||||
<h2>{t("organizations.create_title")}</h2>
|
||||
<p>{t("organizations.create_lead")}</p>
|
||||
</header>
|
||||
<form className="administration-card administration-organization" onSubmit={submit}>
|
||||
<OrganizationLogoField
|
||||
logoUrl={logoPreview}
|
||||
name={draft.name}
|
||||
disabled={false}
|
||||
saving={submitting}
|
||||
onUpload={setLogo}
|
||||
onRemove={() => setLogo(null)}
|
||||
/>
|
||||
<div className="administration-fields">
|
||||
<div className="administration-field-wide">
|
||||
<FormField
|
||||
label={t("admin.organization_name")}
|
||||
value={draft.name}
|
||||
placeholder={t("organizations.name_placeholder")}
|
||||
error={errors.name}
|
||||
onChange={(name) => setDraft({ ...draft, name })}
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
label={t("admin.time_zone")}
|
||||
value={draft.timezone}
|
||||
onChange={(timezone) => setDraft({ ...draft, timezone })}
|
||||
options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])}
|
||||
/>
|
||||
<SelectField
|
||||
label={t("admin.currency")}
|
||||
value={draft.currency}
|
||||
onChange={(currency) => setDraft({ ...draft, currency })}
|
||||
options={[["RUB", t("admin.russian_rouble_rub")]]}
|
||||
/>
|
||||
</div>
|
||||
<div className="appearance-row administration-language">
|
||||
<span>{t("settings.language")}</span>
|
||||
<div className="appearance-theme-options">
|
||||
<button
|
||||
className={draft.language === "" ? "active" : ""}
|
||||
type="button"
|
||||
onClick={() => setDraft({ ...draft, language: "" })}
|
||||
>
|
||||
{t("settings.language_as_installation")}
|
||||
</button>
|
||||
{languages.map((item) => (
|
||||
<button
|
||||
className={draft.language === item.code ? "active" : ""}
|
||||
key={item.code}
|
||||
lang={item.code}
|
||||
type="button"
|
||||
onClick={() => setDraft({ ...draft, language: item.code })}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="settings-section-note">{t("settings.language_org_hint")}</p>
|
||||
{error && <div className="administration-message error" role="alert">{error}</div>}
|
||||
<div className="administration-actions">
|
||||
<Button variant="secondary" disabled={submitting} onClick={onBack}>{t("common.cancel")}</Button>
|
||||
<Button type="submit" variant="primary" icon="plus" disabled={!valid}>
|
||||
{submitting ? t("organizations.creating") : t("organizations.create_submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { api, apiUpload } from "../../api/client";
|
||||
import type { AuthenticatedUser } from "../../types";
|
||||
|
||||
// Создание организации идёт без организации в адресе: её ещё нет. Клиент
|
||||
// такие пути не переписывает (namespace «organizations» не тенантный).
|
||||
const BASE = "/api/v1/organizations/";
|
||||
|
||||
export type OrganizationCreateOptions = {
|
||||
timezones: string[];
|
||||
languages: Array<{ code: string; label: string }>;
|
||||
currencies: string[];
|
||||
};
|
||||
|
||||
export type OrganizationDraft = {
|
||||
name: string;
|
||||
timezone: string;
|
||||
currency: string;
|
||||
// Пустая строка — «как в установке».
|
||||
language: string;
|
||||
};
|
||||
|
||||
export type OrganizationCreated = {
|
||||
user: AuthenticatedUser;
|
||||
organizationPublicId: string;
|
||||
};
|
||||
|
||||
export function loadOrganizationCreateOptions(): Promise<OrganizationCreateOptions> {
|
||||
return api<OrganizationCreateOptions>(`${BASE}options/`);
|
||||
}
|
||||
|
||||
export function createOrganization(draft: OrganizationDraft): Promise<OrganizationCreated> {
|
||||
return api<OrganizationCreated>(BASE, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(draft),
|
||||
});
|
||||
}
|
||||
|
||||
/** Логотип загружается уже в созданную организацию — по её полному адресу,
|
||||
* а не по активной: интерфейс ещё в прежней. */
|
||||
export function uploadNewOrganizationLogo(organizationPublicId: string, file: File): Promise<unknown> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return apiUpload(`${BASE}${organizationPublicId}/company/administration/logo/`, form);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Страница создания организации (переключатель A1 → «Добавить организацию»).
|
||||
Карточка формы — та же, что «Настройки → Организация»; страница лишь даёт
|
||||
ей заголовок, подводку и ширину раздела настроек. */
|
||||
.organization-create {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 28px 40px;
|
||||
}
|
||||
|
||||
.organization-create-head {
|
||||
margin: 14px 0 22px;
|
||||
}
|
||||
|
||||
.organization-create-head h2 {
|
||||
margin: 0;
|
||||
color: var(--n-1);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.organization-create-head p {
|
||||
max-width: 560px;
|
||||
margin: 5px 0 0;
|
||||
color: var(--n-4);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -154,23 +154,8 @@
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
/* Поля профиля: подпись 12.5/600 --n-3, поле 36px на подложке --n-10. */
|
||||
|
||||
.profile-page .readonly-field span {
|
||||
margin-bottom: 6px;
|
||||
color: var(--n-3);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-page .readonly-field input,
|
||||
.profile-page .form-field.is-editable input {
|
||||
height: 36px;
|
||||
color: var(--n-1);
|
||||
background: var(--n-10);
|
||||
border-color: var(--n-7);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
/* Поле профиля моноширинным набором (идентификаторы) — на полступени мельче.
|
||||
Сам скин поля общий, из shared/form-controls.css. */
|
||||
|
||||
.profile-page .readonly-field input.mono {
|
||||
font-size: 13px;
|
||||
@@ -866,11 +851,6 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.profile-page.is-mobile .readonly-field input {
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.profile-page.is-mobile .readonly-field input.mono {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -229,33 +229,14 @@
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* Карточка и поля раздела: подпись 12.5/600 --n-3, поле 36px на подложке
|
||||
--n-10 (кадры N1/N6). */
|
||||
/* Карточка раздела (кадры N1/N6). Поля — общий скин из
|
||||
shared/form-controls.css, своей копии у раздела больше нет. */
|
||||
|
||||
.settings-content .administration-card {
|
||||
padding: 22px 24px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.settings-content .readonly-field span {
|
||||
margin-bottom: 6px;
|
||||
color: var(--n-3);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-content .readonly-field input,
|
||||
.settings-content .readonly-field select,
|
||||
.settings-content .select-like input,
|
||||
.settings-content .select-like select,
|
||||
.settings-content .form-field.is-editable input {
|
||||
height: 36px;
|
||||
color: var(--n-1);
|
||||
background: var(--n-10);
|
||||
border-color: var(--n-7);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
/* Кнопки раздела: primary и вторичная 34px радиусом 8 (кадры N1–N7). */
|
||||
|
||||
.settings-content .primary-button,
|
||||
@@ -913,9 +894,6 @@
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.settings-content .readonly-field input,
|
||||
.settings-content .readonly-field select,
|
||||
.settings-content .select-like select,
|
||||
.settings-content .settings-group-create input {
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
|
||||
@@ -1010,6 +1010,14 @@ export const en: Record<MessageKey, Message> = {
|
||||
"portals.working": "working",
|
||||
"portals.yaml_format_note": "A YAML file · format: articles: [{format}]",
|
||||
"portals.yes_article_helped": "Yes, the article helped",
|
||||
"organizations.add": "Add organization",
|
||||
"organizations.create_title": "New organization",
|
||||
"organizations.create_lead": "You will own the new organization and switch to it right away. The name, time zone and language can be changed later in Settings.",
|
||||
"organizations.create_submit": "Create organization",
|
||||
"organizations.creating": "Creating…",
|
||||
"organizations.create_failed": "Could not create the organization",
|
||||
"organizations.logo_failed": "The organization was created, but the logo failed to upload — add it in Settings.",
|
||||
"organizations.name_placeholder": "For example, “Nord Atelier”",
|
||||
"profile.accent_colour": "Accent colour",
|
||||
"profile.address_unknown": "address unknown",
|
||||
"profile.all_conversations": "All conversations",
|
||||
@@ -1046,6 +1054,9 @@ export const en: Record<MessageKey, Message> = {
|
||||
"profile.getting_started_progress": "{done} of {total}",
|
||||
"profile.go_start_page": "Go to the start page",
|
||||
"profile.switch_organization": "Switch organization",
|
||||
"auth.choose_organization_title": "Choose an organization",
|
||||
"auth.choose_organization_subtitle": "{name}, you belong to several organizations. Which one to start with?",
|
||||
"auth.choose_organization_logout": "Sign out",
|
||||
"auth.invitation_title": "Organization invitation",
|
||||
"auth.invitation_accepting": "Accepting the invitation…",
|
||||
"auth.invitation_not_accepted": "The invitation was not accepted",
|
||||
|
||||
@@ -1011,6 +1011,14 @@ export const ru = {
|
||||
"portals.working": "работает",
|
||||
"portals.yaml_format_note": "Файл YAML · формат: articles: [{format}]",
|
||||
"portals.yes_article_helped": "Да, статья полезна",
|
||||
"organizations.add": "Добавить организацию",
|
||||
"organizations.create_title": "Новая организация",
|
||||
"organizations.create_lead": "Вы станете владельцем новой организации и сразу в неё переключитесь. Название, часовой пояс и язык потом можно поменять в «Настройках».",
|
||||
"organizations.create_submit": "Создать организацию",
|
||||
"organizations.creating": "Создаём…",
|
||||
"organizations.create_failed": "Не удалось создать организацию",
|
||||
"organizations.logo_failed": "Организация создана, но логотип загрузить не удалось — добавьте его в «Настройках».",
|
||||
"organizations.name_placeholder": "Например, «Ателье Норд»",
|
||||
"profile.accent_colour": "Акцентный цвет",
|
||||
"profile.address_unknown": "адрес неизвестен",
|
||||
"profile.all_conversations": "Все диалоги",
|
||||
@@ -1047,6 +1055,9 @@ export const ru = {
|
||||
"profile.getting_started_progress": "{done} из {total}",
|
||||
"profile.go_start_page": "На главную",
|
||||
"profile.switch_organization": "Переключить организацию",
|
||||
"auth.choose_organization_title": "Выберите организацию",
|
||||
"auth.choose_organization_subtitle": "{name}, вы состоите в нескольких организациях. С какой начать?",
|
||||
"auth.choose_organization_logout": "Выйти",
|
||||
"auth.invitation_title": "Приглашение в организацию",
|
||||
"auth.invitation_accepting": "Принимаем приглашение…",
|
||||
"auth.invitation_not_accepted": "Приглашение не принято",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notification as antToast } from "antd";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { AppData, Employee, RouteKey, SessionUser } from "../types";
|
||||
import type { AppData, AuthenticatedUser, Employee, RouteKey, SessionUser } from "../types";
|
||||
import type { SettingsSectionKey } from "../features/settings/sections";
|
||||
import type { PortalSettingsSectionKey } from "../features/support-portals/sections";
|
||||
import { NotificationDrawer } from "../features/notifications/NotificationDrawer";
|
||||
@@ -14,8 +14,8 @@ import { Sidebar } from "./Sidebar";
|
||||
import { UpdateBanner } from "../features/updates/UpdateBanner";
|
||||
import { ShellRouteContent } from "./ShellRouteContent";
|
||||
|
||||
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentRoute, openKnowledgeRoute, openKnowledgeEditorRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout, onSwitchOrganization }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void;
|
||||
openKnowledgeEditorRoute: (knowledgeId: number | null) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; openPortalSettingsRoute: (portalId: number, section?: PortalSettingsSectionKey) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onSwitchOrganization: (organizationPublicId: string) => void }) {
|
||||
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentRoute, openKnowledgeRoute, openKnowledgeEditorRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout, onSwitchOrganization, onOrganizationCreated }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void;
|
||||
openKnowledgeEditorRoute: (knowledgeId: number | null) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; openPortalSettingsRoute: (portalId: number, section?: PortalSettingsSectionKey) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onSwitchOrganization: (organizationPublicId: string) => void; onOrganizationCreated: (identity: AuthenticatedUser, organizationPublicId: string) => void }) {
|
||||
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [notifOpen, setNotifOpen] = useState(false);
|
||||
@@ -121,7 +121,7 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
|
||||
«назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
|
||||
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""} ${isAudit ? "audit-scroll" : ""} ${isPortals ? "portals-scroll" : ""} ${isKnowledge || isKnowledgeEditor ? "knowledge-scroll" : ""}`}>
|
||||
<div key={route} className={`hub-page enter-surface ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isSettings ? "settings-page" : ""} ${isProfile ? "profile-page-shell" : ""} ${isContacts ? "contacts-page-shell" : ""} ${isAgents ? "agents-page-shell" : ""} ${isEmployees ? "employees-page-shell" : ""} ${isAudit ? "audit-page-shell" : ""} ${isKnowledge ? "knowledge-page-shell" : ""} ${isKnowledgeEditor ? "knowledge-editor-shell" : ""} ${isPortals ? "portals-page-shell" : ""}`}>
|
||||
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} selectedEmployeeId={selectedEmployeeId} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} />
|
||||
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} selectedEmployeeId={selectedEmployeeId} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} onOrganizationCreated={onOrganizationCreated} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -6,13 +6,14 @@ import { EmployeeDetailPage, EmployeesPage } from "../features/employees/Employe
|
||||
import { ProfilePage } from "../features/profile/ProfilePage";
|
||||
import { SettingsPage } from "../features/settings/SettingsPage";
|
||||
import { AuditPage } from "../features/administration/AuditPage";
|
||||
import { OrganizationCreatePage } from "../features/organizations/OrganizationCreatePage";
|
||||
import { ChatPage } from "../features/chat/ChatPage";
|
||||
import type { DialogScope } from "../features/conversations/ConversationWorkspace";
|
||||
import type { ConversationCounters } from "../features/conversations/model";
|
||||
import { SalesClientDetailPage } from "../features/sales/client-detail/SalesClientDetailPage";
|
||||
import { SalesClientsPage } from "../features/sales/SalesClientsPage";
|
||||
import { LoadingState } from "../shared/ui";
|
||||
import type { AppData, Employee, RouteKey, SessionUser } from "../types";
|
||||
import type { AppData, AuthenticatedUser, Employee, RouteKey, SessionUser } from "../types";
|
||||
import type { SettingsSectionKey } from "../features/settings/sections";
|
||||
import type { PortalSettingsSectionKey } from "../features/support-portals/sections";
|
||||
import { hasCapability } from "../auth/access";
|
||||
@@ -42,7 +43,7 @@ const SupportPortalDetailPage = lazy(() => import("../features/support-portals/S
|
||||
(module) => ({ default: module.SupportPortalDetailPage }),
|
||||
));
|
||||
|
||||
export function ShellRouteContent({ settingsSection, openSettings, chatScope, setChatScope, chatCounters, chatScopeSwitcher, route, data, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedChannelId, openChannel, selectedSupportPortalId, portalSettingsSection, openSupportPortal, openPortalSettings, openConversation, openEmployee, openAgent, openKnowledge, openKnowledgeEditor, onAgentLoaded, onChannelLoaded, reload, setRoute, user, onUserUpdated, onLogout, onOpenSidebar }: { settingsSection: SettingsSectionKey | null; openSettings: (section: SettingsSectionKey | null) => void; chatScope: DialogScope; setChatScope: (scope: DialogScope) => void; chatCounters: ConversationCounters | null; chatScopeSwitcher: boolean; route: RouteKey; data: AppData; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedChannelId: number | null; openChannel: (channelId: number) => void; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openSupportPortal: (portalId: number) => void; openPortalSettings: (portalId: number, section?: PortalSettingsSectionKey) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; openKnowledgeEditor: (knowledgeId: number | null) => void; onAgentLoaded: (name: string | null) => void; onChannelLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onOpenSidebar: () => void }) {
|
||||
export function ShellRouteContent({ settingsSection, openSettings, chatScope, setChatScope, chatCounters, chatScopeSwitcher, route, data, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedChannelId, openChannel, selectedSupportPortalId, portalSettingsSection, openSupportPortal, openPortalSettings, openConversation, openEmployee, openAgent, openKnowledge, openKnowledgeEditor, onAgentLoaded, onChannelLoaded, reload, setRoute, user, onUserUpdated, onLogout, onOpenSidebar, onOrganizationCreated }: { settingsSection: SettingsSectionKey | null; openSettings: (section: SettingsSectionKey | null) => void; chatScope: DialogScope; setChatScope: (scope: DialogScope) => void; chatCounters: ConversationCounters | null; chatScopeSwitcher: boolean; route: RouteKey; data: AppData; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedChannelId: number | null; openChannel: (channelId: number) => void; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openSupportPortal: (portalId: number) => void; openPortalSettings: (portalId: number, section?: PortalSettingsSectionKey) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; openKnowledgeEditor: (knowledgeId: number | null) => void; onAgentLoaded: (name: string | null) => void; onChannelLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onOpenSidebar: () => void; onOrganizationCreated: (identity: AuthenticatedUser, organizationPublicId: string) => void }) {
|
||||
return (
|
||||
<>
|
||||
{route === "employees" && <EmployeesPage groups={data.groups} openEmployee={openEmployee} setRoute={setRoute} user={user} />}
|
||||
@@ -51,6 +52,7 @@ export function ShellRouteContent({ settingsSection, openSettings, chatScope, se
|
||||
{route === "profile" && <ProfilePage user={user} onUserUpdated={onUserUpdated} reload={reload} onLogout={onLogout} onBack={() => setRoute("chat")} />}
|
||||
{route === "settings" && <SettingsPage user={user} onUserUpdated={onUserUpdated} reload={reload} groups={data.groups} section={settingsSection} openSection={openSettings} setRoute={setRoute} />}
|
||||
{route === "administrationAudit" && <AuditPage />}
|
||||
{route === "organizationCreate" && <OrganizationCreatePage user={user} onCreated={onOrganizationCreated} onBack={() => setRoute("chat")} />}
|
||||
{route === "salesClients" && <SalesClientsPage openClient={openClient} openIntegrations={() => openSettings("integrations")} />}
|
||||
{route === "salesClientDetail" && <SalesClientDetailPage contactId={selectedClientId} canEdit={hasCapability(user, "customers.manage")} canMerge={user.role === "OWNER"} openConversation={openConversation} openClient={openClient} openClients={() => setRoute("salesClients")} />}
|
||||
{route === "chat" && (
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { RouteKey, SessionUser } from "../types";
|
||||
import type { SettingsSectionKey } from "../features/settings/sections";
|
||||
import { useResizableWidth } from "../shared/useResizableWidth";
|
||||
import { Icon, LogoIcon } from "../shared/icons";
|
||||
import { defaultRoute, isManager } from "../auth/access";
|
||||
import { canCreateOrganization, defaultRoute, isManager } from "../auth/access";
|
||||
import type { DialogScope } from "../features/conversations/ConversationWorkspace";
|
||||
import { agentColorOf, groupColorOf, type ConversationCounters } from "../features/conversations/model";
|
||||
import { LaunchChecklist } from "./LaunchChecklist";
|
||||
@@ -123,23 +123,41 @@ export function Sidebar({
|
||||
placement="bottomLeft"
|
||||
overlayClassName="app-dropdown is-wide"
|
||||
menu={{
|
||||
items: user.memberships.map((membership) => ({
|
||||
key: membership.organizationPublicId,
|
||||
label: (
|
||||
<button
|
||||
type="button"
|
||||
className={membership.organizationPublicId === user.organizationPublicId ? "is-checked" : ""}
|
||||
onClick={() => {
|
||||
if (membership.organizationPublicId !== user.organizationPublicId) {
|
||||
onSwitchOrganization(membership.organizationPublicId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="building" size={15} />
|
||||
{membership.organizationName || "Chatballs"}
|
||||
</button>
|
||||
),
|
||||
})),
|
||||
items: [
|
||||
...user.memberships.map((membership) => ({
|
||||
key: membership.organizationPublicId,
|
||||
label: (
|
||||
<button
|
||||
type="button"
|
||||
className={membership.organizationPublicId === user.organizationPublicId ? "is-checked" : ""}
|
||||
onClick={() => {
|
||||
if (membership.organizationPublicId !== user.organizationPublicId) {
|
||||
onSwitchOrganization(membership.organizationPublicId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="building" size={15} />
|
||||
{membership.organizationName || "Chatballs"}
|
||||
</button>
|
||||
),
|
||||
})),
|
||||
// «Добавить организацию» — внизу списка, отделена чертой: ведёт на
|
||||
// страницу создания, где человек становится владельцем новой.
|
||||
...(canCreateOrganization(user)
|
||||
? [
|
||||
{ type: "divider" as const, key: "add-divider" },
|
||||
{
|
||||
key: "add-organization",
|
||||
label: (
|
||||
<button type="button" className="hub-brand-add" onClick={() => setRoute("organizationCreate")}>
|
||||
<span className="hub-brand-add-icon"><Icon name="plus" size={11} strokeWidth={2.4} /></span>
|
||||
{t("organizations.add")}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button className="hub-brand-switch" type="button" title={t("profile.switch_organization")}>
|
||||
|
||||
@@ -208,6 +208,42 @@
|
||||
color: var(--n-4);
|
||||
}
|
||||
|
||||
/* «Добавить организацию» в переключателе (A1): такая же строка, как у
|
||||
организаций, только на месте значка — пунктирная плитка с плюсом, как у
|
||||
«пустого» слота. Ничего залитого: строка читается как действие, а не как
|
||||
ещё одна организация, и не спорит с отмеченной текущей. */
|
||||
.app-dropdown .ant-dropdown-menu-item button.hub-brand-add {
|
||||
color: var(--n-3);
|
||||
}
|
||||
|
||||
.app-dropdown .ant-dropdown-menu-item button.hub-brand-add:hover {
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
/* Селектор длиннее общего «button span { display: block }» из menu.css:
|
||||
иначе плюс прижимается к левому верхнему углу плитки. */
|
||||
.app-dropdown .ant-dropdown-menu-item button .hub-brand-add-icon {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
color: var(--n-4);
|
||||
border: 1.5px dashed var(--n-6);
|
||||
border-radius: 6px;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.hub-brand-add-icon svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.app-dropdown .ant-dropdown-menu-item button.hub-brand-add:hover .hub-brand-add-icon {
|
||||
color: var(--primary-text);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Логотип — сам по себе, без подложки: цвет текста темы. */
|
||||
.hub-brand-mark {
|
||||
width: 28px;
|
||||
|
||||
@@ -14,6 +14,16 @@ const empty = {
|
||||
settingsSection: null,
|
||||
};
|
||||
|
||||
describe("organization create route", () => {
|
||||
// Страница создания живёт вне организации: адрес без uuid, а сборка адреса
|
||||
// не подставляет префикс текущей организации.
|
||||
it("parses and builds /organizations/new", () => {
|
||||
expect(routeFromPath("/organizations/new")).toEqual({ route: "organizationCreate", ...empty });
|
||||
expect(routeFromPath("/organizations/new/")).toEqual({ route: "organizationCreate", ...empty });
|
||||
expect(pathFromRoute("organizationCreate", null, "123e4567-e89b-12d3-a456-426614174000")).toBe("/organizations/new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sales detail routes", () => {
|
||||
it("parses a client detail URL", () => {
|
||||
expect(routeFromPath("/departments/sales/clients/15")).toEqual({ route: "salesClientDetail", ...empty, clientId: 15 });
|
||||
|
||||
@@ -21,6 +21,11 @@ export type RouteState = {
|
||||
|
||||
export function routeFromPath(pathname: string, search = ""): RouteState {
|
||||
const normalized = pathname.replace(/\/+$/, "") || "/";
|
||||
// Страница создания организации живёт вне организации: у неё ещё нет
|
||||
// адреса, а человек попадает сюда из переключателя любой из своих (A1).
|
||||
if (normalized === "/organizations/new") {
|
||||
return { route: "organizationCreate", organizationPublicId: null, employeeId: null, agentId: null, knowledgeId: null, clientId: null, channelId: null, supportPortalId: null, portalSettingsSection: null, settingsSection: null };
|
||||
}
|
||||
const match = normalized.match(/^\/organizations\/([0-9a-f-]{36})(\/.*)?$/i);
|
||||
const organizationPublicId = match?.[1] ?? null;
|
||||
const path = match ? match[2] || "/" : normalized;
|
||||
@@ -114,6 +119,7 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
|
||||
|
||||
export function pathFromRoute(route: RouteKey, entityId: number | string | null = null, organizationPublicId: string | null = null): string {
|
||||
const prefix = organizationPublicId ? `/organizations/${organizationPublicId}` : "";
|
||||
if (route === "organizationCreate") return "/organizations/new";
|
||||
if (route === "salesClients") return `${prefix}/contacts`;
|
||||
if (route === "salesClientDetail") return entityId ? `${prefix}/contacts/${entityId}` : `${prefix}/contacts`;
|
||||
if (route === "chat") return `${prefix}/chat`;
|
||||
|
||||
@@ -22,4 +22,5 @@ export const routes: Record<RouteKey, string> = {
|
||||
knowledgeCategories: t("shared.knowledge_categories"),
|
||||
knowledgeImport: t("shared.knowledge_import"),
|
||||
aiUsage: t("shared.ai_usage"),
|
||||
organizationCreate: t("organizations.create_title"),
|
||||
};
|
||||
@@ -1,3 +1,9 @@
|
||||
/* Поля формы приложения. Геометрия — дизайн-базлайн v2 (кадры G3, N1/N6, P1):
|
||||
подпись 12.5/600 цветом --n-3, поле 36px на подложке --n-10 рамкой --n-7,
|
||||
текст 13.5px. Скин был общим только на бумаге: здесь стояли 38px на
|
||||
--surface-card, а базлайн добирали поверх в профиле, настройках и модалке
|
||||
создания агента — три копии одного поля. Копий больше нет, скин один и
|
||||
живёт здесь. line-height задан явно: внутри antd Modal свой reset. */
|
||||
.readonly-field {
|
||||
display: block;
|
||||
}
|
||||
@@ -6,24 +12,26 @@
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.readonly-field span {
|
||||
.readonly-field > span:first-child {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--n-4);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--n-3);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.readonly-field input {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
color: var(--n-2);
|
||||
background: var(--surface-card);
|
||||
color: var(--n-1);
|
||||
background: var(--n-10);
|
||||
border: 1px solid var(--n-7);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
font-size: 13.5px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.form-field.is-editable:focus-within input,
|
||||
@@ -31,7 +39,7 @@
|
||||
.form-field.is-editable:focus-within textarea,
|
||||
.form-field.is-editable textarea:focus,
|
||||
.select-like:focus-within select,
|
||||
.select-like select:focus {
|
||||
.select-like .select-box:focus-within {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
@@ -57,45 +65,102 @@
|
||||
.form-field.is-readonly input,
|
||||
.readonly-field input:disabled {
|
||||
color: var(--n-4);
|
||||
background: var(--n-10);
|
||||
border-color: var(--n-8);
|
||||
}
|
||||
|
||||
.form-field.is-editable input {
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.select-like {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.select-like input,
|
||||
.select-like select {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding-right: 32px;
|
||||
height: 36px;
|
||||
padding: 0 32px 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-like select {
|
||||
padding-left: 12px;
|
||||
color: var(--n-2);
|
||||
background: var(--surface-card);
|
||||
color: var(--n-1);
|
||||
background: var(--n-10);
|
||||
border: 1px solid var(--n-7);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
font-size: 13px;
|
||||
font-size: 13.5px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.select-like svg {
|
||||
/* Шеврон по центру поля: 36px бокса минус 14px иконки. Отступ справа — те же
|
||||
12px внутреннего поля плюс 1px рамки, что и у шеврона внутри `.select-box`. */
|
||||
.select-like > svg {
|
||||
position: absolute;
|
||||
right: 11px;
|
||||
bottom: 12px;
|
||||
right: 13px;
|
||||
bottom: 11px;
|
||||
color: var(--n-4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Бокс селекта — для метки перед значением (точка группы на карточке агента) и
|
||||
для режима без прав правки. Рамку держит бокс, селект внутри без своей. */
|
||||
.select-like .select-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
color: var(--n-1);
|
||||
background: var(--n-10);
|
||||
border: 1px solid var(--n-7);
|
||||
border-radius: 8px;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.select-like .select-box select {
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.select-like .select-box > svg {
|
||||
flex: none;
|
||||
color: var(--n-4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.select-like .select-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.select-like.is-invalid select,
|
||||
.select-like.is-invalid .select-box {
|
||||
color: var(--error-text);
|
||||
border-color: var(--error-border);
|
||||
}
|
||||
|
||||
/* Узкий экран (мобильные кадры профиля и настроек): поле под палец — 44px и
|
||||
текст 15px. Это тоже лежало копиями в профиле и настройках. */
|
||||
@media (max-width: 900px) {
|
||||
.readonly-field input,
|
||||
.select-like select,
|
||||
.select-like .select-box {
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.select-like .select-box select {
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.select-like > svg {
|
||||
bottom: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Единый переключатель приложения. Раньше в проекте было четыре его копии
|
||||
(.ui-switch, .totp-switch, .detail-totp-switch, .channel-switch) — остался
|
||||
один скин, размер при необходимости задаётся модификатором. */
|
||||
|
||||
@@ -44,14 +44,42 @@ export function FormField({
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectField({ disabled = false, label, value, onChange, options }: { disabled?: boolean; label: string; value: string; onChange: (value: string) => void; options: Array<[string, string]> }) {
|
||||
return (
|
||||
<label className="readonly-field select-like">
|
||||
<span>{label}</span>
|
||||
type SelectFieldProps = {
|
||||
/** Метка перед значением: цветная точка группы на карточке агента. */
|
||||
adornment?: ReactNode;
|
||||
disabled?: boolean;
|
||||
invalid?: boolean;
|
||||
label: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<[string, string]>;
|
||||
/** Нет прав на правку: вместо селекта значение показывается текстом. */
|
||||
readOnly?: boolean;
|
||||
/** Текст для режима без прав, когда значения нет в списке: список вариантов
|
||||
* грузят только тем, кто может править. */
|
||||
readOnlyText?: string;
|
||||
value: string;
|
||||
wide?: boolean;
|
||||
};
|
||||
|
||||
/** Селект приложения — один на всё: подпись, бокс поля и шеврон. Точку перед
|
||||
* значением и режим без прав правки держит `.select-box`: рамка тогда на нём,
|
||||
* а сам селект внутри без рамки и фона. */
|
||||
export function SelectField({ adornment, disabled = false, invalid = false, label, onChange, options, readOnly = false, readOnlyText, value, wide = false }: SelectFieldProps) {
|
||||
const className = ["readonly-field", "select-like", invalid ? "is-invalid" : "", readOnly ? "is-readonly" : "", wide ? "wide" : ""].filter(Boolean).join(" ");
|
||||
const control = readOnly
|
||||
? <span className="select-value">{readOnlyText ?? options.find(([optionValue]) => optionValue === value)?.[1] ?? ""}</span>
|
||||
: (
|
||||
<select disabled={disabled} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map(([optionValue, labelText]) => <option value={optionValue} key={optionValue}>{labelText}</option>)}
|
||||
</select>
|
||||
<Icon name="chevron" size={14} />
|
||||
);
|
||||
const chevron = <Icon name="chevron" size={14} strokeWidth={2.2} />;
|
||||
const boxed = Boolean(adornment) || readOnly;
|
||||
|
||||
return (
|
||||
<label className={className}>
|
||||
<span>{label}</span>
|
||||
{boxed ? <span className="select-box">{adornment}{control}{chevron}</span> : <>{control}{chevron}</>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
@import "./features/ai/knowledge/styles-pages.css";
|
||||
@import "./features/integrations/styles.css";
|
||||
@import "./features/administration/styles.css";
|
||||
@import "./features/organizations/styles.css";
|
||||
@import "./features/settings/styles.css";
|
||||
@import "./features/notifications/styles.css";
|
||||
@import "./shared/state.css";
|
||||
@@ -124,7 +124,7 @@ export type EmployeeAuditEvent = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type RouteKey = "administrationAudit" | "employeeDetail" | "employees" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "chat" | "supportPortals" | "supportPortalDetail" | "supportPortalSettings" | "agents" | "agentDetail" | "knowledge" | "knowledgeDetail" | "knowledgeCreate" | "knowledgeEdit" | "knowledgeCategories" | "knowledgeImport" | "aiUsage";
|
||||
export type RouteKey = "administrationAudit" | "employeeDetail" | "employees" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "chat" | "supportPortals" | "supportPortalDetail" | "supportPortalSettings" | "agents" | "agentDetail" | "knowledge" | "knowledgeDetail" | "knowledgeCreate" | "knowledgeEdit" | "knowledgeCategories" | "knowledgeImport" | "aiUsage" | "organizationCreate";
|
||||
|
||||
export type AppData = {
|
||||
groups: EmployeeGroup[];
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
.readonly-field > span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.readonly-field input,
|
||||
@@ -23,13 +24,22 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.readonly-field input,
|
||||
.select-like select,
|
||||
.search-control {
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Поле формы — дизайн-базлайн v2 (кадры G3, N1/N6, P1): 36px на подложке
|
||||
--n-10, текст 13.5px. Один скин на всё приложение. */
|
||||
.readonly-field input,
|
||||
.select-like select {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
color: var(--n-1);
|
||||
background: var(--n-10);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.readonly-field textarea {
|
||||
min-height: 92px;
|
||||
padding: 10px 12px;
|
||||
@@ -40,6 +50,7 @@
|
||||
.form-field.is-editable:focus-within input,
|
||||
.form-field.is-editable:focus-within textarea,
|
||||
.select-like:focus-within select,
|
||||
.select-like .select-box:focus-within,
|
||||
.search-control:focus-within {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
@@ -71,14 +82,67 @@
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.select-like .select-chevron {
|
||||
.select-like > .select-chevron {
|
||||
position: absolute;
|
||||
right: 11px;
|
||||
bottom: 12px;
|
||||
right: 13px;
|
||||
bottom: 11px;
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Бокс селекта — для метки перед значением (точка группы на карточке агента) и
|
||||
для режима без прав правки. Рамку держит бокс, селект внутри без своей. */
|
||||
.select-like .select-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
color: var(--n-1);
|
||||
background: var(--n-10);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.select-like .select-box select {
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.select-like .select-box > .select-chevron {
|
||||
flex: none;
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Пример метки перед значением: цветная точка группы. */
|
||||
.select-like .select-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.select-like .select-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.select-like.is-invalid select,
|
||||
.select-like.is-invalid .select-box {
|
||||
color: var(--error-text);
|
||||
border-color: var(--error-border);
|
||||
}
|
||||
|
||||
.search-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -95,6 +95,9 @@
|
||||
<div class="ds-specimen-header"><strong>Select & textarea</strong><code>SelectField · TextAreaField</code></div>
|
||||
<div class="ds-specimen-body is-column">
|
||||
<label class="readonly-field select-like"><span>Статус</span><select aria-label="Статус"><option>Активен</option><option>Неактивен</option></select><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></label>
|
||||
<label class="readonly-field select-like"><span>Группа</span><span class="select-box"><i class="select-dot" style="background:#4096ff;"></i><select aria-label="Группа"><option>Поддержка</option><option>Продажи</option></select><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></span></label>
|
||||
<label class="readonly-field select-like is-invalid"><span>Провайдер</span><select aria-label="Провайдер"><option>Не выбран</option></select><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></label>
|
||||
<label class="readonly-field select-like is-readonly"><span>Группа без прав правки</span><span class="select-box"><span class="select-value">Без группы</span><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></span></label>
|
||||
<label class="readonly-field form-field is-editable"><span>Описание</span><textarea aria-label="Описание">Канал обработки обращений клиентов.</textarea></label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -407,6 +407,97 @@ test("с несколькими организациями вход открыв
|
||||
await expect(managerNav(page)).toHaveCount(MANAGER_NAV.length);
|
||||
});
|
||||
|
||||
test("после входа с несколькими организациями показывается выбор, ссылка на организацию его минует", async ({ page }) => {
|
||||
const secondMembership = membershipFor("OWNER", {
|
||||
id: 3,
|
||||
organizationPublicId: SECOND_ORGANIZATION_PUBLIC_ID,
|
||||
organization: "second",
|
||||
organizationName: "Вторая организация",
|
||||
positionTitle: "Директор",
|
||||
});
|
||||
const identity = identityFor("OWNER", [membershipFor("OWNER"), secondMembership]);
|
||||
await login(page, identity);
|
||||
|
||||
// Экран выбора: обе организации строками с ролью, приложение ещё не открыто.
|
||||
await expect(page.getByRole("heading", { name: "Выберите организацию" })).toBeVisible();
|
||||
const list = page.locator(".auth-org-list");
|
||||
await expect(list.getByRole("button")).toHaveCount(2);
|
||||
await expect(list.getByRole("button", { name: /Вторая организация/ })).toContainText("Директор");
|
||||
await expect(managerNav(page)).toHaveCount(0);
|
||||
|
||||
await list.getByRole("button", { name: /Вторая организация/ }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/organizations/${SECOND_ORGANIZATION_PUBLIC_ID}/chat`));
|
||||
await expect(page.locator(".hub-brand-switch span")).toHaveText("Вторая организация");
|
||||
await expect(managerNav(page)).toHaveCount(MANAGER_NAV.length);
|
||||
|
||||
// Прямая ссылка на организацию: вход ведёт сразу в неё (на стартовый
|
||||
// экран, как и всегда после входа), без выбора.
|
||||
await mockSession(page, null);
|
||||
await page.goto(`/organizations/${ORGANIZATION_PUBLIC_ID}/employees`);
|
||||
await page.getByPlaceholder("you@domain.ru").fill("user@example.com");
|
||||
await page.getByPlaceholder("Пароль").fill("Password-123");
|
||||
await page.getByRole("button", { name: "Войти" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/organizations/${ORGANIZATION_PUBLIC_ID}/chat`));
|
||||
await expect(page.locator(".hub-brand-switch span")).toHaveText("Ателье Норд");
|
||||
await expect(page.getByRole("heading", { name: "Выберите организацию" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("владелец добавляет организацию из переключателя и сразу в неё попадает", async ({ page }) => {
|
||||
const NEW_ORGANIZATION_PUBLIC_ID = "323e4567-e89b-12d3-a456-426614174000";
|
||||
await mockInstance(page);
|
||||
await mockEmployees(page);
|
||||
await mockSession(page, OWNER_IDENTITY);
|
||||
await page.route("**/api/v1/organizations/options/", (route) =>
|
||||
route.fulfill({ json: { timezones: ["Europe/Moscow", "Europe/Berlin"], languages: [{ code: "ru", label: "Русский" }, { code: "en", label: "English" }], currencies: ["RUB"] } }),
|
||||
);
|
||||
let created: Record<string, unknown> | null = null;
|
||||
await page.route("**/api/v1/organizations/", (route) => {
|
||||
created = route.request().postDataJSON();
|
||||
const membership = membershipFor("OWNER", {
|
||||
id: 9,
|
||||
organizationPublicId: NEW_ORGANIZATION_PUBLIC_ID,
|
||||
organization: "vtoraya",
|
||||
organizationName: "Вторая компания",
|
||||
});
|
||||
return route.fulfill({
|
||||
status: 201,
|
||||
json: { user: identityFor("OWNER", [membershipFor("OWNER"), membership]), organizationPublicId: NEW_ORGANIZATION_PUBLIC_ID },
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await expect(managerNav(page)).toHaveCount(MANAGER_NAV.length);
|
||||
|
||||
// В переключателе (A1) под списком организаций — «Добавить организацию».
|
||||
await page.locator(".hub-brand-switch").click();
|
||||
await page.locator(".app-dropdown").getByRole("button", { name: "Добавить организацию" }).click();
|
||||
await expect(page).toHaveURL(/\/organizations\/new$/);
|
||||
await expect(page.getByRole("heading", { name: "Новая организация" })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder("Например, «Ателье Норд»").fill("Вторая компания");
|
||||
await page.getByRole("button", { name: "English" }).click();
|
||||
await page.getByRole("button", { name: "Создать организацию" }).click();
|
||||
|
||||
// Сразу в новой организации: адрес и переключатель показывают её.
|
||||
await expect(page).toHaveURL(new RegExp(`/organizations/${NEW_ORGANIZATION_PUBLIC_ID}/chat`));
|
||||
await expect(page.locator(".hub-brand-switch span")).toHaveText("Вторая компания");
|
||||
expect(created).toEqual({ name: "Вторая компания", timezone: "Europe/Moscow", currency: "RUB", language: "en" });
|
||||
});
|
||||
|
||||
test("сотрудник без прав менеджера не видит «Добавить организацию»", async ({ page }) => {
|
||||
await mockInstance(page);
|
||||
await mockEmployees(page);
|
||||
await mockSession(page, identityFor("EMPLOYEE"));
|
||||
|
||||
await page.goto("/");
|
||||
await page.locator(".hub-brand-switch").click();
|
||||
|
||||
const menu = page.locator(".app-dropdown");
|
||||
await expect(menu.getByRole("button", { name: "Ателье Норд" })).toBeVisible();
|
||||
await expect(menu.getByRole("button", { name: "Добавить организацию" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("интерфейс работает на минимальной поддерживаемой ширине 1024px", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1024, height: 768 });
|
||||
await mockInstance(page);
|
||||
|
||||
Reference in new issue
Block a user