✨ 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:
AndreyandClaude Fable 5.1 committed 2026-09-13 04:14:24 +03:00
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"}
+10
View File
@@ -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)
@@ -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)]
+48 -3
View File
@@ -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(),
+31 -2
View File
@@ -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>
);
+8
View File
@@ -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;
}
+11
View File
@@ -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",
+11
View File
@@ -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": "Приглашение не принято",
+4 -4
View File
@@ -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" && (
+36 -18
View File
@@ -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")}>
+36
View File
@@ -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;
+10
View File
@@ -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 });
+6
View File
@@ -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`;
+1
View File
@@ -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
View File
@@ -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";
+1 -1
View File
@@ -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[];
+91
View File
@@ -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);