mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(identity): организации из интерфейса — создание, выбор после входа, /join под ролью app
Кнопка «Добавить организацию» внизу переключателя у логотипа (A1) ведёт на страницу /organizations/new: логотип, название, часовой пояс, валюта, язык. Создавший становится владельцем и сразу переключается в новую организацию. Право — у администратора установки и у владельца или администратора любой организации; сервер проверяет то же (POST /api/v1/organizations/). После входа учётная запись с несколькими организациями выбирает, с какой начать: экран в рамке входа, строки «логотип · название · роль». Прямая ссылка на организацию экран минует. tenancy/0035: роль app вставляет организацию только в контексте заранее выделенного id (как мастер первого запуска) вместо политики «только первая»; security-barrier каталог invitation_directory — ссылка /join открывается без контекста, и под ролью app приглашение раньше не находилось вовсе. Тем же путём язык организации в профиле: членства читаются через каталог входа. Тесты: создание организации по API, RLS под реальной ролью app (вставка только в своём контексте, поиск приглашения по токену), e2e переключателя, страницы создания и экрана выбора. UpdateState исключён из проверки покрытия демо-набором — одна строка на установку. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
eca0e52667
commit
82805f7b1a
35 files changed
+1179
-53
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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[];
|
||||
|
||||
@@ -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