diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index 68d1a6d..4451830 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -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", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index 0621b0d..310e90a 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -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": "Установлена последняя версия", diff --git a/apps/backend/chatballs/identity/administration_services.py b/apps/backend/chatballs/identity/administration_services.py index f6187ed..0394c16 100644 --- a/apps/backend/chatballs/identity/administration_services.py +++ b/apps/backend/chatballs/identity/administration_services.py @@ -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 ) diff --git a/apps/backend/chatballs/identity/audit_catalog.py b/apps/backend/chatballs/identity/audit_catalog.py index 509bc75..70a2b78 100644 --- a/apps/backend/chatballs/identity/audit_catalog.py +++ b/apps/backend/chatballs/identity/audit_catalog.py @@ -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", # --- Интеграции и каналы --- diff --git a/apps/backend/chatballs/identity/auth/profile.py b/apps/backend/chatballs/identity/auth/profile.py index 0fb8143..7ceb390 100644 --- a/apps/backend/chatballs/identity/auth/profile.py +++ b/apps/backend/chatballs/identity/auth/profile.py @@ -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 "" diff --git a/apps/backend/chatballs/identity/invitation_service.py b/apps/backend/chatballs/identity/invitation_service.py index 644f8a2..ac2d538 100644 --- a/apps/backend/chatballs/identity/invitation_service.py +++ b/apps/backend/chatballs/identity/invitation_service.py @@ -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( diff --git a/apps/backend/chatballs/identity/organization_creation.py b/apps/backend/chatballs/identity/organization_creation.py new file mode 100644 index 0000000..b31d05d --- /dev/null +++ b/apps/backend/chatballs/identity/organization_creation.py @@ -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) diff --git a/apps/backend/chatballs/identity/organization_urls.py b/apps/backend/chatballs/identity/organization_urls.py new file mode 100644 index 0000000..fc8bd6f --- /dev/null +++ b/apps/backend/chatballs/identity/organization_urls.py @@ -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", + ), +] diff --git a/apps/backend/chatballs/identity/organization_views.py b/apps/backend/chatballs/identity/organization_views.py new file mode 100644 index 0000000..7e398fe --- /dev/null +++ b/apps/backend/chatballs/identity/organization_views.py @@ -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, + ) diff --git a/apps/backend/chatballs/identity/test_organization_creation.py b/apps/backend/chatballs/identity/test_organization_creation.py new file mode 100644 index 0000000..84223b9 --- /dev/null +++ b/apps/backend/chatballs/identity/test_organization_creation.py @@ -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"]) diff --git a/apps/backend/chatballs/identity/test_seed_demo.py b/apps/backend/chatballs/identity/test_seed_demo.py index b755a72..c2732ab 100644 --- a/apps/backend/chatballs/identity/test_seed_demo.py +++ b/apps/backend/chatballs/identity/test_seed_demo.py @@ -50,6 +50,8 @@ COVERAGE_EXEMPT = { # Настройки установки (адрес, по которому её открывают) — тоже одна # строка на инстанс: их пишет мастер первого запуска, а не демо. ("identity", "instancesettings"), + # Состояние обновлений установки — одна строка на инстанс (updates/0001). + ("updates", "updatestate"), } COVERAGE_EXEMPT_APPS = {"platform", "events"} diff --git a/apps/backend/chatballs/tenancy/ingress.py b/apps/backend/chatballs/tenancy/ingress.py index 88eb24e..872b227 100644 --- a/apps/backend/chatballs/tenancy/ingress.py +++ b/apps/backend/chatballs/tenancy/ingress.py @@ -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) diff --git a/apps/backend/chatballs/tenancy/migrations/0035_organization_creation_and_invitation_directory.py b/apps/backend/chatballs/tenancy/migrations/0035_organization_creation_and_invitation_directory.py new file mode 100644 index 0000000..e215a9d --- /dev/null +++ b/apps/backend/chatballs/tenancy/migrations/0035_organization_creation_and_invitation_directory.py @@ -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)] diff --git a/apps/backend/chatballs/tenancy/test_rls.py b/apps/backend/chatballs/tenancy/test_rls.py index e561131..47db4ba 100644 --- a/apps/backend/chatballs/tenancy/test_rls.py +++ b/apps/backend/chatballs/tenancy/test_rls.py @@ -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: diff --git a/apps/backend/chatballs_backend/urls_app.py b/apps/backend/chatballs_backend/urls_app.py index 3d06676..2df652a 100644 --- a/apps/backend/chatballs_backend/urls_app.py +++ b/apps/backend/chatballs_backend/urls_app.py @@ -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/", DemoMediaView.as_view(), diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index 361dfa3..9adae18 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -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(null); const [organizationPublicId, setOrganizationPublicId] = useState(initialRoute.organizationPublicId); const [totpChallenge, setTotpChallenge] = useState(null); + // Учётная запись с несколькими организациями после входа выбирает, с какой + // начать. Ссылка на конкретную организацию экран выбора минует. + const [organizationChoice, setOrganizationChoice] = useState(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() { {totpChallenge ? ( { setTotpChallenge(null); landAfterAuth(nextUser); }} /> + ) : organizationChoice ? ( + ) : !identity ? ( joinToken && !joinNeedsLogin && !needsSetup ? ( @@ -217,7 +246,7 @@ export function App() { ) : !canAccess(user, navigation.route) ? ( navigate(defaultRoute(user), null, true)} /> ) : ( - 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} /> + 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} /> )} ); diff --git a/apps/internal-ui/src/auth/access.ts b/apps/internal-ui/src/auth/access.ts index 62fd2ef..9f9b24a 100644 --- a/apps/internal-ui/src/auth/access.ts +++ b/apps/internal-ui/src/auth/access.ts @@ -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); } diff --git a/apps/internal-ui/src/features/auth/AuthChooseOrganization.tsx b/apps/internal-ui/src/features/auth/AuthChooseOrganization.tsx new file mode 100644 index 0000000..c45d037 --- /dev/null +++ b/apps/internal-ui/src/features/auth/AuthChooseOrganization.tsx @@ -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 = ( + + ); + + return ( + +
    + {identity.memberships.map((membership) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/apps/internal-ui/src/features/auth/AuthScreens.tsx b/apps/internal-ui/src/features/auth/AuthScreens.tsx index f15ab34..60a26d3 100644 --- a/apps/internal-ui/src/features/auth/AuthScreens.tsx +++ b/apps/internal-ui/src/features/auth/AuthScreens.tsx @@ -1,4 +1,5 @@ export { AuthChangePassword } from "./AuthChangePassword"; +export { AuthChooseOrganization } from "./AuthChooseOrganization"; export { AuthJoin } from "./AuthJoin"; export { AuthJoinGuest } from "./AuthJoinGuest"; export { AuthLogin } from "./AuthLogin"; diff --git a/apps/internal-ui/src/features/auth/styles.css b/apps/internal-ui/src/features/auth/styles.css index 13145de..2cc6a3c 100644 --- a/apps/internal-ui/src/features/auth/styles.css +++ b/apps/internal-ui/src/features/auth/styles.css @@ -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; diff --git a/apps/internal-ui/src/features/organizations/OrganizationCreatePage.tsx b/apps/internal-ui/src/features/organizations/OrganizationCreatePage.tsx new file mode 100644 index 0000000..bb9a146 --- /dev/null +++ b/apps/internal-ui/src/features/organizations/OrganizationCreatePage.tsx @@ -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>; + +export function OrganizationCreatePage({ user, onCreated, onBack }: { + user: SessionUser; + onCreated: (identity: AuthenticatedUser, organizationPublicId: string) => void; + onBack: () => void; +}) { + const [options, setOptions] = useState(null); + const [draft, setDraft] = useState({ + name: "", + // Новая организация наследует региональные параметры текущей: чаще всего + // человек заводит вторую компанию там же, где первую. + timezone: "Europe/Moscow", + currency: "RUB", + language: "", + }); + const [logo, setLogo] = useState(null); + const [errors, setErrors] = useState({}); + 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 ( +
+ +
+

{t("organizations.create_title")}

+

{t("organizations.create_lead")}

+
+
+ setLogo(null)} + /> +
+
+ setDraft({ ...draft, name })} + /> +
+ setDraft({ ...draft, timezone })} + options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])} + /> + setDraft({ ...draft, currency })} + options={[["RUB", t("admin.russian_rouble_rub")]]} + /> +
+
+ {t("settings.language")} +
+ + {languages.map((item) => ( + + ))} +
+
+

{t("settings.language_org_hint")}

+ {error &&
{error}
} +
+ + +
+ +
+ ); +} diff --git a/apps/internal-ui/src/features/organizations/api.ts b/apps/internal-ui/src/features/organizations/api.ts new file mode 100644 index 0000000..01e4c99 --- /dev/null +++ b/apps/internal-ui/src/features/organizations/api.ts @@ -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 { + return api(`${BASE}options/`); +} + +export function createOrganization(draft: OrganizationDraft): Promise { + return api(BASE, { + method: "POST", + body: JSON.stringify(draft), + }); +} + +/** Логотип загружается уже в созданную организацию — по её полному адресу, + * а не по активной: интерфейс ещё в прежней. */ +export function uploadNewOrganizationLogo(organizationPublicId: string, file: File): Promise { + const form = new FormData(); + form.append("file", file); + return apiUpload(`${BASE}${organizationPublicId}/company/administration/logo/`, form); +} diff --git a/apps/internal-ui/src/features/organizations/styles.css b/apps/internal-ui/src/features/organizations/styles.css new file mode 100644 index 0000000..ef68e9d --- /dev/null +++ b/apps/internal-ui/src/features/organizations/styles.css @@ -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; +} diff --git a/apps/internal-ui/src/i18n/en.ts b/apps/internal-ui/src/i18n/en.ts index 7af0e14..bff2dab 100644 --- a/apps/internal-ui/src/i18n/en.ts +++ b/apps/internal-ui/src/i18n/en.ts @@ -1010,6 +1010,14 @@ export const en: Record = { "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 = { "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", diff --git a/apps/internal-ui/src/i18n/ru.ts b/apps/internal-ui/src/i18n/ru.ts index 83574a6..ac65568 100644 --- a/apps/internal-ui/src/i18n/ru.ts +++ b/apps/internal-ui/src/i18n/ru.ts @@ -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": "Приглашение не принято", diff --git a/apps/internal-ui/src/layout/Shell.tsx b/apps/internal-ui/src/layout/Shell.tsx index 01f49f4..36627c5 100644 --- a/apps/internal-ui/src/layout/Shell.tsx +++ b/apps/internal-ui/src/layout/Shell.tsx @@ -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([]); const [unreadCount, setUnreadCount] = useState(0); const [notifOpen, setNotifOpen] = useState(false); @@ -121,7 +121,7 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel «назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
- undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} /> + undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} onOrganizationCreated={onOrganizationCreated} />
diff --git a/apps/internal-ui/src/layout/ShellRouteContent.tsx b/apps/internal-ui/src/layout/ShellRouteContent.tsx index 3844663..e3a230a 100644 --- a/apps/internal-ui/src/layout/ShellRouteContent.tsx +++ b/apps/internal-ui/src/layout/ShellRouteContent.tsx @@ -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" && } @@ -51,6 +52,7 @@ export function ShellRouteContent({ settingsSection, openSettings, chatScope, se {route === "profile" && setRoute("chat")} />} {route === "settings" && } {route === "administrationAudit" && } + {route === "organizationCreate" && setRoute("chat")} />} {route === "salesClients" && openSettings("integrations")} />} {route === "salesClientDetail" && setRoute("salesClients")} />} {route === "chat" && ( diff --git a/apps/internal-ui/src/layout/Sidebar.tsx b/apps/internal-ui/src/layout/Sidebar.tsx index ce9545e..61e8c05 100644 --- a/apps/internal-ui/src/layout/Sidebar.tsx +++ b/apps/internal-ui/src/layout/Sidebar.tsx @@ -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: ( - - ), - })), + items: [ + ...user.memberships.map((membership) => ({ + key: membership.organizationPublicId, + label: ( + + ), + })), + // «Добавить организацию» — внизу списка, отделена чертой: ведёт на + // страницу создания, где человек становится владельцем новой. + ...(canCreateOrganization(user) + ? [ + { type: "divider" as const, key: "add-divider" }, + { + key: "add-organization", + label: ( + + ), + }, + ] + : []), + ], }} >