Compare commits

...
3 Commits
Author SHA1 Message Date
AndreyandClaude Fable 5.1 1da84c9734 🔖 release: 1.6.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:15:14 +03:00
AndreyandClaude Fable 5.1 82805f7b1a ✨ 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>
2026-09-13 04:14:24 +03:00
AndreyandClaude Opus 5 eca0e52667 ♻️ refactor(ui): один селект и один скин полей на всё приложение
Карточка агента рисовала свои выпадающие селекты: «Группа» и «Провайдер»
жили на собственной вёрстке и своём CSS, а рядом, в модалке создания
агента, стоял общий SelectField. Два стандарта одного элемента успели
разойтись в высоте, подложке, размере текста, подписи и фокусе.

Общий SelectField научился тому, из-за чего карточка писала свой: метке
перед значением (точка группы), невалидному состоянию и режиму без прав
правки, где значение показывается текстом. Форм у него две — обычный
селект и бокс, который держит рамку, когда внутрь нужно поставить точку
или текст вместо контрола. Карточка перешла на него, локальный скин
удалён, кольцо фокуса кадра G2 сохранено.

Геометрия базлайна v2 (поле 36px на подложке n-10, текст 13.5px, подпись
12.5/600) стала дефолтом shared/form-controls.css. До этого дефолтом
были 38px на surface-card, а базлайн добирали поверх профиль, настройки
и модалка создания агента — вместе с карточкой четыре копии одного поля.
Копии сняты, мобильные 44px тоже общие. Заодно шеврон встал по центру
поля: его отступ считался от прежних 38px.

Справочник дизайн-системы обновлён следом, чтобы не описывать геометрию,
которой в приложении больше нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 03:59:10 +03:00
44 changed files with 1408 additions and 215 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.5.0
1.6.0
@@ -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);
}
@@ -5,6 +5,7 @@ import { LANGUAGES } from "@chatballs/shared";
import { api, ApiError } from "../../api/client";
import { ChannelGlyph } from "../../shared/badges";
import { SelectField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { EmptyState, ErrorScreen, LoadingState } from "../../shared/ui";
import { BackLink, Button, CopyButton } from "../../shared/ui-controls";
@@ -509,25 +510,16 @@ function AssignmentCard({ card, groups, canManage, busy, apply }: {
? <input value={name} onChange={(event) => setName(event.target.value)} onBlur={() => { if (dirty) void apply({ name: name.trim() }); }} />
: <span className="agent-field-static">{card.name}</span>}
</label>
<label className="agent-field is-select">
<span>{t("common.group")}</span>
<span className="agent-field-control">
<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />
{canManage ? (
<select
disabled={busy}
value={card.groupId === null ? "" : String(card.groupId)}
onChange={(event) => void apply({ groupId: event.target.value ? Number(event.target.value) : null })}
>
<option value="">{t("common.no_group")}</option>
{groups.map((group) => <option value={String(group.id)} key={group.id}>{group.name}</option>)}
</select>
) : (
<span className="agent-field-static">{card.groupName ?? t("common.no_group")}</span>
)}
<Icon name="chevron" size={14} strokeWidth={2.2} />
</span>
</label>
<SelectField
adornment={<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />}
disabled={busy}
label={t("common.group")}
readOnly={!canManage}
readOnlyText={card.groupName ?? t("common.no_group")}
value={card.groupId === null ? "" : String(card.groupId)}
onChange={(next) => void apply({ groupId: next ? Number(next) : null })}
options={[["", t("common.no_group")], ...groups.map((group) => [String(group.id), group.name] as [string, string])]}
/>
</div>
</section>
);
@@ -558,24 +550,16 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
<h3>{t("common.model")}</h3>
<p>{t("ai.provider_key_lives_under_settings")}</p>
<div className="agent-side-fields">
<label className={`agent-field is-select ${missingProvider ? "is-invalid" : ""}`}>
<span>{t("ai.provider")}</span>
<span className="agent-field-control">
{canManage ? (
<select
disabled={busy}
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
onChange={(event) => void apply({ providerIntegrationId: event.target.value ? Number(event.target.value) : null })}
>
<option value="">{t("ai.not_selected")}</option>
{providers.map((item) => <option value={String(item.id)} key={item.id}>{item.name}</option>)}
</select>
) : (
<span className="agent-field-static">{providerName || t("ai.not_selected")}</span>
)}
<Icon name="chevron" size={14} strokeWidth={2.2} />
</span>
</label>
<SelectField
disabled={busy}
invalid={missingProvider}
label={t("ai.provider")}
readOnly={!canManage}
readOnlyText={providerName || t("ai.not_selected")}
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
onChange={(next) => void apply({ providerIntegrationId: next ? Number(next) : null })}
options={[["", t("ai.not_selected")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
/>
<label className="agent-field is-model">
<span>{t("common.model")}</span>
<span className="agent-field-control">
@@ -352,23 +352,6 @@
gap: 14px;
}
.agent-create-modal .readonly-field span,
.agent-knowledge-modal .readonly-field span {
margin-bottom: 6px;
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
line-height: normal;
}
.agent-create-modal .readonly-field input,
.agent-create-modal .select-like select {
height: 36px;
background: var(--n-10);
font-size: 13.5px;
line-height: normal;
}
.agent-create-actions {
display: flex;
justify-content: flex-end;
@@ -800,7 +783,6 @@
.agent-field textarea,
.agent-field input,
.agent-field select,
.agent-field .agent-field-static {
width: 100%;
padding: 0 12px;
@@ -814,7 +796,6 @@
}
.agent-field input,
.agent-field select,
.agent-field > .agent-field-static {
height: 36px;
line-height: 34px;
@@ -859,7 +840,6 @@
/* Фокус поля — как в кадре G2: акцентная рамка, кольцо 3px и подложка карточки. */
.agent-field textarea:focus,
.agent-field input:focus,
.agent-field select:focus,
.agent-create-modal .form-field.is-editable input:focus,
.agent-create-modal .select-like select:focus {
border-color: var(--primary);
@@ -867,7 +847,9 @@
box-shadow: 0 0 0 3px var(--primary-bg);
}
.agent-field-control:focus-within {
/* Тот же фокус для общего селекта карточки: с боксом (группа) и без (провайдер). */
.agent-side-fields .select-like select:focus,
.agent-side-fields .select-box:focus-within {
border-color: var(--primary);
background: var(--surface-card);
box-shadow: 0 0 0 3px var(--primary-bg);
@@ -1028,23 +1010,6 @@
font-size: 13.5px;
}
.agent-field.is-invalid .agent-field-control {
border-color: var(--error-border);
color: var(--error-text);
}
.agent-field-control select {
flex: 1;
height: 34px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font-size: 13.5px;
appearance: none;
cursor: pointer;
}
.agent-field-control .agent-field-static {
flex: 1;
height: auto;
@@ -0,0 +1,57 @@
import { Icon } from "../../shared/icons";
import { roleLabel } from "../../shared/ui";
import { AuthFrame } from "./AuthFrame";
import { t } from "../../i18n";
import type { AuthenticatedUser } from "../../types";
// Выбор организации после входа — для тех, кто состоит в нескольких. Экран
// в той же матовой рамке, что вход и приглашение: список организаций
// строками «логотип · название · роль». Ссылка на конкретную организацию
// этот экран минует: адрес уже сказал, куда идти.
function initials(name: string): string {
return name.trim().split(/\s+/).map((part) => part[0] ?? "").join("").slice(0, 2).toUpperCase() || "CB";
}
export function AuthChooseOrganization({ identity, onChoose, onLogout }: {
identity: AuthenticatedUser;
onChoose: (organizationPublicId: string) => void;
onLogout: () => void;
}) {
const logoutLink = (
<button type="button" className="auth-back-login" onClick={onLogout}><Icon name="logout" size={14} />{t("auth.choose_organization_logout")}</button>
);
return (
<AuthFrame
title={t("auth.choose_organization_title")}
subtitle={t("auth.choose_organization_subtitle", { name: identity.fullName || identity.email })}
logo="pulse"
width={420}
note={logoutLink}
>
<ul className="auth-card auth-org-list">
{identity.memberships.map((membership) => (
<li key={membership.organizationPublicId}>
<button
type="button"
className="auth-org-item"
onClick={() => onChoose(membership.organizationPublicId)}
>
<span className={`auth-org-mark ${membership.organizationLogoUrl ? "has-logo" : ""}`}>
{membership.organizationLogoUrl
? <img src={membership.organizationLogoUrl} alt="" />
: initials(membership.organizationName)}
</span>
<span className="auth-org-copy">
<strong>{membership.organizationName}</strong>
<small>{membership.positionTitle || roleLabel(membership.role)}</small>
</span>
<Icon name="chevronRight" size={16} />
</button>
</li>
))}
</ul>
</AuthFrame>
);
}
@@ -1,4 +1,5 @@
export { AuthChangePassword } from "./AuthChangePassword";
export { AuthChooseOrganization } from "./AuthChooseOrganization";
export { AuthJoin } from "./AuthJoin";
export { AuthJoinGuest } from "./AuthJoinGuest";
export { AuthLogin } from "./AuthLogin";
@@ -603,6 +603,102 @@
font-weight: 600;
}
/* Выбор организации после входа: строки «логотип · название · роль» в той же
матовой рамке, что и вход. Плитка логотипа — как знак в сайдбаре (28px),
только крупнее; строка целиком — кнопка. */
.auth-org-list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.auth-org-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px 10px 10px;
color: var(--n-1);
background: var(--surface-card);
border: 1px solid var(--n-8);
border-radius: 10px;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.auth-org-item:hover,
.auth-org-item:focus-visible {
background: var(--primary-bg);
border-color: var(--primary-border);
outline: none;
}
.auth-org-item > svg {
flex: none;
color: var(--n-5);
}
.auth-org-item:hover > svg {
color: var(--primary-text);
}
.auth-org-mark {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
overflow: hidden;
color: var(--primary-text);
background: var(--primary-bg);
border-radius: 9px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
}
.auth-org-mark.has-logo {
background: var(--surface-card);
border: 1px solid var(--n-7);
}
.auth-org-mark img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.auth-org-copy {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
gap: 2px;
}
.auth-org-copy strong {
overflow: hidden;
color: var(--n-1);
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.auth-org-copy small {
overflow: hidden;
color: var(--n-4);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.auth-back-login {
display: inline-flex;
align-items: center;
@@ -0,0 +1,167 @@
import { type FormEvent, useEffect, useMemo, useState } from "react";
import { ApiError } from "../../api/client";
import { OrganizationLogoField } from "../administration/OrganizationLogoField";
import { FormField, SelectField } from "../../shared/form-controls";
import { BackLink, Button } from "../../shared/ui-controls";
import { timezoneLabel } from "../../shared/utils";
import { t } from "../../i18n";
import type { AuthenticatedUser, SessionUser } from "../../types";
import {
createOrganization,
loadOrganizationCreateOptions,
uploadNewOrganizationLogo,
type OrganizationCreateOptions,
type OrganizationDraft,
} from "./api";
// Страница создания организации (переключатель A1 → «Добавить организацию»).
// Те же поля, что в «Настройках → Организация»: имя, часовой пояс, валюта,
// язык, логотип. Создавший становится владельцем и сразу переключается в
// новую организацию — ответ сервера уже содержит обновлённый список членств.
type FieldErrors = Partial<Record<keyof OrganizationDraft, string>>;
export function OrganizationCreatePage({ user, onCreated, onBack }: {
user: SessionUser;
onCreated: (identity: AuthenticatedUser, organizationPublicId: string) => void;
onBack: () => void;
}) {
const [options, setOptions] = useState<OrganizationCreateOptions | null>(null);
const [draft, setDraft] = useState<OrganizationDraft>({
name: "",
// Новая организация наследует региональные параметры текущей: чаще всего
// человек заводит вторую компанию там же, где первую.
timezone: "Europe/Moscow",
currency: "RUB",
language: "",
});
const [logo, setLogo] = useState<File | null>(null);
const [errors, setErrors] = useState<FieldErrors>({});
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
let active = true;
loadOrganizationCreateOptions()
.then((payload) => { if (active) setOptions(payload); })
.catch((requestError) => {
if (active) setError(requestError instanceof ApiError ? requestError.message : t("common.request_failed"));
});
return () => { active = false; };
}, []);
const logoPreview = useMemo(() => (logo ? URL.createObjectURL(logo) : null), [logo]);
useEffect(() => () => { if (logoPreview) URL.revokeObjectURL(logoPreview); }, [logoPreview]);
const valid = draft.name.trim() !== "" && !submitting;
async function submit(event: FormEvent) {
event.preventDefault();
if (!valid) return;
setSubmitting(true);
setError("");
setErrors({});
let created;
try {
created = await createOrganization(draft);
} catch (requestError) {
if (requestError instanceof ApiError) {
const fieldErrors = (requestError.payload as { errors?: FieldErrors }).errors;
if (fieldErrors) setErrors(fieldErrors);
setError(requestError.message);
} else {
setError(t("organizations.create_failed"));
}
setSubmitting(false);
return;
}
if (logo) {
// Логотип — уже в созданную организацию. Его неудача организацию не
// отменяет: человек попадает в неё и доложит логотип в «Настройках».
try {
await uploadNewOrganizationLogo(created.organizationPublicId, logo);
} catch {
window.setTimeout(() => window.alert(t("organizations.logo_failed")), 0);
}
}
onCreated(created.user, created.organizationPublicId);
}
const timezones = options?.timezones ?? [draft.timezone];
const languages = options?.languages ?? [];
return (
<div className="organization-create">
<BackLink label={user.organizationName || "Chatballs"} onClick={onBack} />
<header className="organization-create-head">
<h2>{t("organizations.create_title")}</h2>
<p>{t("organizations.create_lead")}</p>
</header>
<form className="administration-card administration-organization" onSubmit={submit}>
<OrganizationLogoField
logoUrl={logoPreview}
name={draft.name}
disabled={false}
saving={submitting}
onUpload={setLogo}
onRemove={() => setLogo(null)}
/>
<div className="administration-fields">
<div className="administration-field-wide">
<FormField
label={t("admin.organization_name")}
value={draft.name}
placeholder={t("organizations.name_placeholder")}
error={errors.name}
onChange={(name) => setDraft({ ...draft, name })}
/>
</div>
<SelectField
label={t("admin.time_zone")}
value={draft.timezone}
onChange={(timezone) => setDraft({ ...draft, timezone })}
options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])}
/>
<SelectField
label={t("admin.currency")}
value={draft.currency}
onChange={(currency) => setDraft({ ...draft, currency })}
options={[["RUB", t("admin.russian_rouble_rub")]]}
/>
</div>
<div className="appearance-row administration-language">
<span>{t("settings.language")}</span>
<div className="appearance-theme-options">
<button
className={draft.language === "" ? "active" : ""}
type="button"
onClick={() => setDraft({ ...draft, language: "" })}
>
{t("settings.language_as_installation")}
</button>
{languages.map((item) => (
<button
className={draft.language === item.code ? "active" : ""}
key={item.code}
lang={item.code}
type="button"
onClick={() => setDraft({ ...draft, language: item.code })}
>
{item.label}
</button>
))}
</div>
</div>
<p className="settings-section-note">{t("settings.language_org_hint")}</p>
{error && <div className="administration-message error" role="alert">{error}</div>}
<div className="administration-actions">
<Button variant="secondary" disabled={submitting} onClick={onBack}>{t("common.cancel")}</Button>
<Button type="submit" variant="primary" icon="plus" disabled={!valid}>
{submitting ? t("organizations.creating") : t("organizations.create_submit")}
</Button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,44 @@
import { api, apiUpload } from "../../api/client";
import type { AuthenticatedUser } from "../../types";
// Создание организации идёт без организации в адресе: её ещё нет. Клиент
// такие пути не переписывает (namespace «organizations» не тенантный).
const BASE = "/api/v1/organizations/";
export type OrganizationCreateOptions = {
timezones: string[];
languages: Array<{ code: string; label: string }>;
currencies: string[];
};
export type OrganizationDraft = {
name: string;
timezone: string;
currency: string;
// Пустая строка — «как в установке».
language: string;
};
export type OrganizationCreated = {
user: AuthenticatedUser;
organizationPublicId: string;
};
export function loadOrganizationCreateOptions(): Promise<OrganizationCreateOptions> {
return api<OrganizationCreateOptions>(`${BASE}options/`);
}
export function createOrganization(draft: OrganizationDraft): Promise<OrganizationCreated> {
return api<OrganizationCreated>(BASE, {
method: "POST",
body: JSON.stringify(draft),
});
}
/** Логотип загружается уже в созданную организацию — по её полному адресу,
* а не по активной: интерфейс ещё в прежней. */
export function uploadNewOrganizationLogo(organizationPublicId: string, file: File): Promise<unknown> {
const form = new FormData();
form.append("file", file);
return apiUpload(`${BASE}${organizationPublicId}/company/administration/logo/`, form);
}
@@ -0,0 +1,29 @@
/* Страница создания организации (переключатель A1 → «Добавить организацию»).
Карточка формы — та же, что «Настройки → Организация»; страница лишь даёт
ей заголовок, подводку и ширину раздела настроек. */
.organization-create {
width: 100%;
max-width: 720px;
margin: 0 auto;
padding: 24px 28px 40px;
}
.organization-create-head {
margin: 14px 0 22px;
}
.organization-create-head h2 {
margin: 0;
color: var(--n-1);
font-size: 20px;
font-weight: 700;
letter-spacing: -0.01em;
}
.organization-create-head p {
max-width: 560px;
margin: 5px 0 0;
color: var(--n-4);
font-size: 13px;
line-height: 1.5;
}
@@ -154,23 +154,8 @@
color: var(--error-text);
}
/* Поля профиля: подпись 12.5/600 --n-3, поле 36px на подложке --n-10. */
.profile-page .readonly-field span {
margin-bottom: 6px;
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
}
.profile-page .readonly-field input,
.profile-page .form-field.is-editable input {
height: 36px;
color: var(--n-1);
background: var(--n-10);
border-color: var(--n-7);
font-size: 13.5px;
}
/* Поле профиля моноширинным набором (идентификаторы) — на полступени мельче.
Сам скин поля общий, из shared/form-controls.css. */
.profile-page .readonly-field input.mono {
font-size: 13px;
@@ -866,11 +851,6 @@
gap: 14px;
}
.profile-page.is-mobile .readonly-field input {
height: 44px;
font-size: 15px;
}
.profile-page.is-mobile .readonly-field input.mono {
font-size: 14px;
}
@@ -229,33 +229,14 @@
font-size: 12.5px;
}
/* Карточка и поля раздела: подпись 12.5/600 --n-3, поле 36px на подложке
--n-10 (кадры N1/N6). */
/* Карточка раздела (кадры N1/N6). Поля — общий скин из
shared/form-controls.css, своей копии у раздела больше нет. */
.settings-content .administration-card {
padding: 22px 24px;
box-shadow: none;
}
.settings-content .readonly-field span {
margin-bottom: 6px;
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
}
.settings-content .readonly-field input,
.settings-content .readonly-field select,
.settings-content .select-like input,
.settings-content .select-like select,
.settings-content .form-field.is-editable input {
height: 36px;
color: var(--n-1);
background: var(--n-10);
border-color: var(--n-7);
font-size: 13.5px;
}
/* Кнопки раздела: primary и вторичная 34px радиусом 8 (кадры N1–N7). */
.settings-content .primary-button,
@@ -913,9 +894,6 @@
padding-inline: 14px;
}
.settings-content .readonly-field input,
.settings-content .readonly-field select,
.settings-content .select-like select,
.settings-content .settings-group-create input {
height: 44px;
font-size: 15px;
+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"),
};
+92 -27
View File
@@ -1,3 +1,9 @@
/* Поля формы приложения. Геометрия — дизайн-базлайн v2 (кадры G3, N1/N6, P1):
подпись 12.5/600 цветом --n-3, поле 36px на подложке --n-10 рамкой --n-7,
текст 13.5px. Скин был общим только на бумаге: здесь стояли 38px на
--surface-card, а базлайн добирали поверх в профиле, настройках и модалке
создания агента — три копии одного поля. Копий больше нет, скин один и
живёт здесь. line-height задан явно: внутри antd Modal свой reset. */
.readonly-field {
display: block;
}
@@ -6,24 +12,26 @@
grid-column: span 2;
}
.readonly-field span {
.readonly-field > span:first-child {
display: block;
margin-bottom: 6px;
color: var(--n-4);
font-size: 12px;
font-weight: 500;
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
line-height: normal;
}
.readonly-field input {
width: 100%;
height: 38px;
height: 36px;
padding: 0 12px;
color: var(--n-2);
background: var(--surface-card);
color: var(--n-1);
background: var(--n-10);
border: 1px solid var(--n-7);
border-radius: 8px;
outline: none;
font-size: 13px;
font-size: 13.5px;
line-height: normal;
}
.form-field.is-editable:focus-within input,
@@ -31,7 +39,7 @@
.form-field.is-editable:focus-within textarea,
.form-field.is-editable textarea:focus,
.select-like:focus-within select,
.select-like select:focus {
.select-like .select-box:focus-within {
border-color: var(--primary);
}
@@ -57,45 +65,102 @@
.form-field.is-readonly input,
.readonly-field input:disabled {
color: var(--n-4);
background: var(--n-10);
border-color: var(--n-8);
}
.form-field.is-editable input {
background: var(--surface-card);
}
.select-like {
position: relative;
}
.select-like input,
.select-like select {
width: 100%;
height: 38px;
padding-right: 32px;
height: 36px;
padding: 0 32px 0 12px;
cursor: pointer;
}
.select-like select {
padding-left: 12px;
color: var(--n-2);
background: var(--surface-card);
color: var(--n-1);
background: var(--n-10);
border: 1px solid var(--n-7);
border-radius: 8px;
outline: none;
appearance: none;
font-size: 13px;
font-size: 13.5px;
line-height: normal;
}
.select-like svg {
/* Шеврон по центру поля: 36px бокса минус 14px иконки. Отступ справа — те же
12px внутреннего поля плюс 1px рамки, что и у шеврона внутри `.select-box`. */
.select-like > svg {
position: absolute;
right: 11px;
bottom: 12px;
right: 13px;
bottom: 11px;
color: var(--n-4);
pointer-events: none;
}
/* Бокс селекта — для метки перед значением (точка группы на карточке агента) и
для режима без прав правки. Рамку держит бокс, селект внутри без своей. */
.select-like .select-box {
display: flex;
align-items: center;
gap: 7px;
height: 36px;
padding: 0 12px;
color: var(--n-1);
background: var(--n-10);
border: 1px solid var(--n-7);
border-radius: 8px;
font-size: 13.5px;
}
.select-like .select-box select {
flex: 1;
height: 34px;
padding: 0;
color: inherit;
background: transparent;
border: 0;
font-size: inherit;
}
.select-like .select-box > svg {
flex: none;
color: var(--n-4);
pointer-events: none;
}
.select-like .select-value {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.select-like.is-invalid select,
.select-like.is-invalid .select-box {
color: var(--error-text);
border-color: var(--error-border);
}
/* Узкий экран (мобильные кадры профиля и настроек): поле под палец — 44px и
текст 15px. Это тоже лежало копиями в профиле и настройках. */
@media (max-width: 900px) {
.readonly-field input,
.select-like select,
.select-like .select-box {
height: 44px;
font-size: 15px;
}
.select-like .select-box select {
height: 42px;
}
.select-like > svg {
bottom: 15px;
}
}
/* Единый переключатель приложения. Раньше в проекте было четыре его копии
(.ui-switch, .totp-switch, .detail-totp-switch, .channel-switch) — остался
один скин, размер при необходимости задаётся модификатором. */
+33 -5
View File
@@ -44,14 +44,42 @@ export function FormField({
);
}
export function SelectField({ disabled = false, label, value, onChange, options }: { disabled?: boolean; label: string; value: string; onChange: (value: string) => void; options: Array<[string, string]> }) {
return (
<label className="readonly-field select-like">
<span>{label}</span>
type SelectFieldProps = {
/** Метка перед значением: цветная точка группы на карточке агента. */
adornment?: ReactNode;
disabled?: boolean;
invalid?: boolean;
label: string;
onChange: (value: string) => void;
options: Array<[string, string]>;
/** Нет прав на правку: вместо селекта значение показывается текстом. */
readOnly?: boolean;
/** Текст для режима без прав, когда значения нет в списке: список вариантов
* грузят только тем, кто может править. */
readOnlyText?: string;
value: string;
wide?: boolean;
};
/** Селект приложения — один на всё: подпись, бокс поля и шеврон. Точку перед
* значением и режим без прав правки держит `.select-box`: рамка тогда на нём,
* а сам селект внутри без рамки и фона. */
export function SelectField({ adornment, disabled = false, invalid = false, label, onChange, options, readOnly = false, readOnlyText, value, wide = false }: SelectFieldProps) {
const className = ["readonly-field", "select-like", invalid ? "is-invalid" : "", readOnly ? "is-readonly" : "", wide ? "wide" : ""].filter(Boolean).join(" ");
const control = readOnly
? <span className="select-value">{readOnlyText ?? options.find(([optionValue]) => optionValue === value)?.[1] ?? ""}</span>
: (
<select disabled={disabled} value={value} onChange={(event) => onChange(event.target.value)}>
{options.map(([optionValue, labelText]) => <option value={optionValue} key={optionValue}>{labelText}</option>)}
</select>
<Icon name="chevron" size={14} />
);
const chevron = <Icon name="chevron" size={14} strokeWidth={2.2} />;
const boxed = Boolean(adornment) || readOnly;
return (
<label className={className}>
<span>{label}</span>
{boxed ? <span className="select-box">{adornment}{control}{chevron}</span> : <>{control}{chevron}</>}
</label>
);
}
+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[];
@@ -5,9 +5,10 @@
.readonly-field > span {
display: block;
margin-bottom: 6px;
color: var(--text-tertiary);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
font-size: 12.5px;
font-weight: 600;
line-height: normal;
}
.readonly-field input,
@@ -23,13 +24,22 @@
font-size: 13px;
}
.readonly-field input,
.select-like select,
.search-control {
height: 38px;
padding: 0 12px;
}
/* Поле формы — дизайн-базлайн v2 (кадры G3, N1/N6, P1): 36px на подложке
--n-10, текст 13.5px. Один скин на всё приложение. */
.readonly-field input,
.select-like select {
height: 36px;
padding: 0 12px;
color: var(--n-1);
background: var(--n-10);
font-size: 13.5px;
}
.readonly-field textarea {
min-height: 92px;
padding: 10px 12px;
@@ -40,6 +50,7 @@
.form-field.is-editable:focus-within input,
.form-field.is-editable:focus-within textarea,
.select-like:focus-within select,
.select-like .select-box:focus-within,
.search-control:focus-within {
border-color: var(--primary);
}
@@ -71,14 +82,67 @@
appearance: none;
}
.select-like .select-chevron {
.select-like > .select-chevron {
position: absolute;
right: 11px;
bottom: 12px;
right: 13px;
bottom: 11px;
color: var(--text-tertiary);
pointer-events: none;
}
/* Бокс селекта — для метки перед значением (точка группы на карточке агента) и
для режима без прав правки. Рамку держит бокс, селект внутри без своей. */
.select-like .select-box {
display: flex;
align-items: center;
gap: 7px;
height: 36px;
padding: 0 12px;
color: var(--n-1);
background: var(--n-10);
border: 1px solid var(--border-input);
border-radius: var(--radius-md);
font-size: 13.5px;
}
.select-like .select-box select {
flex: 1;
height: 34px;
padding: 0;
color: inherit;
background: transparent;
border: 0;
font-size: inherit;
}
.select-like .select-box > .select-chevron {
flex: none;
color: var(--text-tertiary);
pointer-events: none;
}
/* Пример метки перед значением: цветная точка группы. */
.select-like .select-dot {
width: 8px;
height: 8px;
flex: none;
border-radius: 50%;
}
.select-like .select-value {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.select-like.is-invalid select,
.select-like.is-invalid .select-box {
color: var(--error-text);
border-color: var(--error-border);
}
.search-control {
display: flex;
align-items: center;
+3
View File
@@ -95,6 +95,9 @@
<div class="ds-specimen-header"><strong>Select & textarea</strong><code>SelectField · TextAreaField</code></div>
<div class="ds-specimen-body is-column">
<label class="readonly-field select-like"><span>Статус</span><select aria-label="Статус"><option>Активен</option><option>Неактивен</option></select><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></label>
<label class="readonly-field select-like"><span>Группа</span><span class="select-box"><i class="select-dot" style="background:#4096ff;"></i><select aria-label="Группа"><option>Поддержка</option><option>Продажи</option></select><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></span></label>
<label class="readonly-field select-like is-invalid"><span>Провайдер</span><select aria-label="Провайдер"><option>Не выбран</option></select><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></label>
<label class="readonly-field select-like is-readonly"><span>Группа без прав правки</span><span class="select-box"><span class="select-value">Без группы</span><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></span></label>
<label class="readonly-field form-field is-editable"><span>Описание</span><textarea aria-label="Описание">Канал обработки обращений клиентов.</textarea></label>
</div>
</article>
+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);