Compare commits

..
8 Commits
Author SHA1 Message Date
AndreyandClaude Fable 5.1 a4ca7e9373 🔖 release: 1.3.0
Несколько организаций на одной установке: переключатель организаций,
приглашения существующих учётных записей и владельца из провижининга,
администратор установки, минимальные привилегии процессов и три тома
секретов. Миграции tenancy 0031–0033, identity 0036–0037; настройки
установки переехали на /api/v1/instance/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 16:19:01 +03:00
AndreyandClaude Fable 5.1 145c44a17d ✨ feat(internal-ui): несколько организаций — переключатель, разделы администратора установки, приглашения
Переключатель организаций в сайдбаре (дизайн-базлайн v2, A1) показывает все
организации человека, текущая отмечена, выбор другой пересобирает Shell по
ключу организации. Вход без организации в адресе открывает последнюю
открытую или первую по списку вместо экрана «нет доступа»; предпочтение
переехало в localStorage.

Разделы «Платформа» и «Хранилище файлов» видны только администратору
установки, relay для звонков у остальных менеджеров только на чтение;
настройки установки запрашиваются с /api/v1/instance/.

Ссылка-приглашение /join: вошедший принимает приглашение и попадает в новую
организацию, гость без учётной записи задаёт имя и пароль полями мастера
первого запуска. Ожидающие приглашения показаны в списке сотрудников
строками «Приглашён» с меню «отправить ещё раз» и «отозвать» — теми же
элементами, что строка сотрудника.

e2e: проекту internal-ui задана русская локаль браузера, моки сессии отдают
язык установки — сценарии перестали зависеть от языка машины; добавлены
сценарии переключателя, строки приглашения и регистрации гостя.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:52 +03:00
AndreyandClaude Fable 5.1 ac824e192c 🔒 feat(deploy): три тома секретов и разрешение upstream в nginx на каждом запросе
Том chatballs-secrets целиком монтировался во все сервисы: публичный
backend-app читал пароли платформенной роли и роли миграций, которая обходит
RLS. Теперь томов три — общий, платформенной роли и роли миграций вместе с
паролем владельца кластера — и каждый процесс монтирует только свои.
Генератор переносит файлы существующих установок: копия, побайтная сверка,
только потом удаление из общего тома; повторный запуск ничего не трогает,
пароли не меняются. doctor на работающем стеке проверяет, что backend-app
паролей не видит. Воркер получает алиас platform флагом
CHATBALLS_DB_PLATFORM_ALIAS. README перечисляет три тома в бэкапе.

nginx (dev-шлюз и production-фронтенд) резолвил имя сервиса один раз при
старте: после пересоздания контейнеров старый адрес backend-app достался
backend-platform, и туда уходил WebSocket приложения. Upstream задан
переменной с resolver на DNS Docker. ADR-CHATBALLS-0048 §3, §5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:38 +03:00
AndreyandClaude Fable 5.1 3e790af6e5 ✉️ feat(identity): приглашения в организацию — существующие учётные записи и владелец из провижининга
Учётная запись глобальная, а во вторую организацию было не попасть: форма
создания сотрудника отвечала «e-mail занят». Теперь занятый адрес из другой
организации получает приглашение с той же ролью, должностью, телефоном и
группами; членство появляется, когда человек принял его под своим входом.
Письмо отправляет воркер и выпускает токен в момент отправки — открытый
токен нигде не хранится. Повторное приглашение заменяет прежнее.

Приглашение владельца из платформенного провижининга доходит тем же
письмом. По ссылке /join предпросмотр говорит, есть ли учётная запись:
существующая идёт на вход и принимает приглашение, новая задаёт имя и пароль
(регистрация с теми же правилами, что в мастере) и активирует организацию.

Ожидающие приглашения отдаются в списке сотрудников блоком invitations с
действиями «отправить ещё раз» и «отозвать». Ответ на ошибки формы полями
переехал из мастера в auth/common — им пользуется и регистрация.
ADR-CHATBALLS-0047.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:25 +03:00
AndreyandClaude Fable 5.1 1390ccc426 🔒 fix(tenancy): гранты платформенной роли, backend-app только ролью app, организация видна в своём контексте
Провижининг организаций через платформенный API работал только в тестах:
роль chatballs_platform не имела прав на таблицы платформы, и уже проверка
токена падала с permission denied. Тесты ходят в базу владельцем кластера и
этого не видели — новый тест выполняет запрос под реальной runtime-ролью.

Публичный процесс backend-app держал platform-соединение ради каталогов
входа и мастера первого запуска. Теперь каталоги читаются ролью app
(SELECT на security-barrier вьюхи), а первую организацию мастер создаёт под
политикой «пока организаций нет» через SECURITY DEFINER-функцию; алиас
platform поднимается только у платформенной поверхности и воркера.

Роль app читала таблицу организаций целиком. Политика сужена до текущей
организации: входы без контекста находят id через каталог организаций и
читают строку уже внутри tenant_atomic (tenancy/lookup), воркер обходит
организации по каталогу, мастер выделяет id из последовательности заранее.
Должность владельца при провижининге берётся из словаря на языке
организации. ADR-CHATBALLS-0048.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:10 +03:00
AndreyandClaude Fable 5.1 2e52bdaea0 👥 feat(identity): администратор установки и настройки инсталляции на /api/v1/instance/
Настройки установки — адрес, почта, TURN, хранилище файлов, язык по
умолчанию — общие для всех организаций, а редактировал их любой владелец
любой организации через путь под организацией. Теперь у учётной записи есть
глобальный признак is_instance_admin: первым его получает владелец из
мастера первого запуска, существующим установкам — те, у кого был
is_superuser. Эндпоинты переехали на /api/v1/instance/ вне tenant
middleware: читает менеджер любой организации (ради карточки relay),
меняет только администратор установки. Аудит и событие переноса файлов —
уровня установки, без организации. Передача признака — командой
set_instance_admin и полем в Django admin; последнего активного
администратора отозвать нельзя.

Сессия отдаёт isInstanceAdmin. Старые пути под организацией отвечают 404.
ADR-CHATBALLS-0046.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:36:51 +03:00
AndreyandClaude Opus 5 5bf3d9b572 🔖 release: 1.2.1
Тег на зелёном коммите: в v1.2.0 проверки CI упали на порядке импортов.
Кода релиз не меняет — правка уже в main отдельным коммитом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 03:36:56 +03:00
AndreyandClaude Opus 5 6d94aa1f9f 🚨 fix(lint): порядок импортов по конфигурации из корня репозитория
Проверка ruff в контейнере шла из /app/apps/backend, где корневого
pyproject.toml нет, — правило I (сортировка импортов) там просто не
применялось, и CI поймал то, что локальный прогон пропустил. Правки чисто
механические: порядок импортов и пустые строки, оставшиеся от удалённых
констант.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 03:20:50 +03:00
97 changed files with 2597 additions and 290 deletions

No files matched your search

+2 -2
View File
@@ -311,13 +311,13 @@ All services should be `healthy` or `running`. Application readiness is availabl
<details>
<summary><strong>Do not delete the secrets volume</strong></summary>
The `chatballs-secrets` volume holds the encryption key. Without it, integration tokens, SMTP passwords, S3 keys and two-factor secrets become unreadable. Include this volume in backups together with the database and files.
The `chatballs-secrets` volume holds the encryption key. Without it, integration tokens, SMTP passwords, S3 keys and two-factor secrets become unreadable. The `chatballs-secrets-platform` and `chatballs-secrets-schema` volumes hold the database role passwords: without them the stack cannot connect to its own database. Include all three volumes in backups together with the database and files.
</details>
<details>
<summary><strong>Backup</strong></summary>
Copy the `chatballs-postgres`, `chatballs-media` and `chatballs-secrets` volumes. A database dump can be taken as well:
Copy the `chatballs-postgres`, `chatballs-media`, `chatballs-secrets`, `chatballs-secrets-platform` and `chatballs-secrets-schema` volumes. A database dump can be taken as well:
```bash
docker compose exec -T postgres pg_dump -U chatballs_bootstrap chatballs > backup.sql
+2 -2
View File
@@ -311,13 +311,13 @@ docker compose ps
<details>
<summary><strong>Не удаляйте том с секретами</strong></summary>
В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. Включайте этот том в резервные копии вместе с базой и файлами.
В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. В томах `chatballs-secrets-platform` и `chatballs-secrets-schema` лежат пароли ролей базы: без них стек не подключится к собственной базе. Включайте все три тома в резервные копии вместе с базой и файлами.
</details>
<details>
<summary><strong>Резервная копия</strong></summary>
Копируйте тома `chatballs-postgres`, `chatballs-media` и `chatballs-secrets`. Для базы можно снять дамп:
Копируйте тома `chatballs-postgres`, `chatballs-media`, `chatballs-secrets`, `chatballs-secrets-platform` и `chatballs-secrets-schema`. Для базы можно снять дамп:
```bash
docker compose exec -T postgres pg_dump -U chatballs_bootstrap chatballs > backup.sql
+1 -1
View File
@@ -1 +1 @@
1.2.0
1.3.0
@@ -4,10 +4,10 @@ from rest_framework.request import Request
from rest_framework.views import APIView
from chatballs.ai.models import KnowledgeAttachment
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import attachment_route
from chatballs.tenancy.lookup import load_organization
class AttachmentDownloadView(APIView):
@@ -19,10 +19,9 @@ class AttachmentDownloadView(APIView):
route = attachment_route(str(public_id))
if route is None:
raise Http404
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist as error:
raise Http404 from error
organization = load_organization(route.organization_id)
if organization is None:
raise Http404
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
attachment = KnowledgeAttachment.objects.filter(
@@ -21,11 +21,11 @@ at runtime and overrides `AIAgent.model`.
from __future__ import annotations
from chatballs.i18n import t
from chatballs.ai.provider.base import LLMProvider, ProviderError
from chatballs.ai.provider.custom import CustomProvider
from chatballs.ai.provider.demo import DemoProvider
from chatballs.ai.provider.openrouter import OpenRouterProvider
from chatballs.i18n import t
from chatballs.integrations.models import Integration, IntegrationProvider
@@ -14,8 +14,8 @@ from chatballs.calls.models import CallInvite, CallSession, CallStatus, InviteDe
from chatballs.calls.services import CALL_INVITE_SEND
from chatballs.calls.tokens import issue_invite_token
from chatballs.conversations import transports
from chatballs.i18n import customer_language, t
from chatballs.events.handlers import register
from chatballs.i18n import customer_language, t
from chatballs.identity.instance_settings import public_base_url
from chatballs.tenancy.context import TenantContext
@@ -27,10 +27,11 @@ from chatballs.calls.tokens import (
verify_call_access_token,
)
from chatballs.i18n import t
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.identity.models import OrganizationMembership
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import call_invite_route, call_session_route
from chatballs.tenancy.lookup import load_organization
@dataclass(frozen=True)
@@ -45,10 +46,9 @@ def resolve_invite(*, token: str) -> ResolvedInvite:
route = call_invite_route(token_hash)
if route is None:
raise CallTokenError(message)
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist:
raise CallTokenError(message) from None
organization = load_organization(route.organization_id)
if organization is None:
raise CallTokenError(message)
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
invite = (
@@ -92,10 +92,9 @@ def _authorize_call_access(
route = call_session_route(str(claims.call_session_id))
if route is None:
raise CallTokenError(t("calls.token_invalid"))
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist:
raise CallTokenError(t("calls.token_invalid")) from None
organization = load_organization(route.organization_id)
if organization is None:
raise CallTokenError(t("calls.token_invalid"))
resource_context = TenantContext.for_resource(organization)
with tenant_atomic(resource_context):
try:
@@ -22,8 +22,9 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer
from chatballs.conversations.models import Conversation
from chatballs.conversations.realtime import conversation_group, inbox_group
from chatballs.conversations.selectors import conversation_is_visible
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.identity.models import OrganizationMembership
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.lookup import organization_by_public_id
logger = logging.getLogger(__name__)
@@ -82,8 +83,10 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer):
@database_sync_to_async
def _membership(self, user_id: int, raw_public_id: str) -> tuple[int, int] | None:
try:
organization = Organization.objects.get(public_id=uuid.UUID(str(raw_public_id)))
except (ValueError, Organization.DoesNotExist):
organization = organization_by_public_id(uuid.UUID(str(raw_public_id)))
except ValueError:
return None
if organization is None:
return None
with tenant_atomic(organization.pk):
membership = (
@@ -19,11 +19,6 @@ from chatballs.integrations.models import IntegrationProvider
from chatballs.tenancy.context import TenantContext
class ClaimError(Exception):
pass
@@ -11,10 +11,10 @@ from chatballs.conversations.poller import poll_all_messengers
from chatballs.events.handlers import dispatch
from chatballs.events.models import OutboxStatus
from chatballs.events.services import claim_next_outbox_event, mark_retry
from chatballs.identity.models import Organization
from chatballs.notifications.binding import poll_notifier_bots
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.lookup import iter_organizations
logger = logging.getLogger(__name__)
@@ -28,7 +28,7 @@ class Command(BaseCommand):
@staticmethod
def _tenant_contexts():
for organization in Organization.objects.order_by("id").iterator():
for organization in iter_organizations():
yield TenantContext.for_resource(
organization, actor_kind=TenantActorKind.SYSTEM
)
+5 -2
View File
@@ -7,8 +7,9 @@ from django.utils import timezone
from chatballs.events.context import get_correlation_id
from chatballs.events.models import EventOwnership, OutboxEvent, OutboxStatus
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.identity.models import OrganizationMembership
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.lookup import load_organization
@dataclass(frozen=True)
@@ -43,7 +44,9 @@ def tenant_context_for_event(event: OutboxEvent) -> TenantContext | None:
return None
if event.organization_id is None:
raise ValueError("Tenant event has no organization")
organization = Organization.objects.get(pk=event.organization_id)
organization = load_organization(event.organization_id)
if organization is None:
raise ValueError("Tenant event organization does not exist")
try:
actor_kind = TenantActorKind(event.actor_kind)
except ValueError as error:
@@ -82,6 +82,8 @@ MESSAGES: dict[str, object] = {
"emails.initial_access_subject": "Your Chatballs access",
"emails.password_reset_body": "Hello {name},\n\nYou asked to reset your Chatballs password. To set a new one, follow this link:\n{url}\n\nThe link is valid for 30 minutes. If you did not ask for a reset, simply ignore this email.",
"emails.password_reset_subject": "Chatballs access recovery",
"emails.membership_invitation_subject": "Invitation to {organization}",
"emails.membership_invitation_body": "Hello, {name}.\n\nYou are invited to join the organization \u201c{organization}\u201d in Chatballs. To accept the invitation, follow this link:\n{url}\n\nThe link is valid for 7 days. If you did not expect this e-mail, simply ignore it.",
"identity.link_invalid_or_expired": "The link is not valid or has expired",
"identity.password_needs_digit": "The password must contain a digit.",
"identity.password_needs_letter": "The password must contain a letter.",
@@ -208,6 +210,9 @@ MESSAGES: dict[str, object] = {
"audit.action_identity_avatar_updated": "Profile photo changed",
"audit.action_identity_employee_blocked": "Operator blocked",
"audit.action_identity_employee_created": "Operator added",
"audit.action_identity_employee_invited": "Operator invited",
"audit.action_identity_invitation_accepted": "Organization invitation accepted",
"audit.action_identity_invitation_revoked": "Operator invitation revoked",
"audit.action_identity_employee_groups_changed": "Operator groups changed",
"audit.action_identity_employee_password_reset": "Operator password reset",
"audit.action_identity_employee_privileged_action_denied": "Privileged action denied",
@@ -327,6 +332,7 @@ MESSAGES: dict[str, object] = {
"identity.invalid_totp_code": "Invalid TOTP code",
"identity.invitation_email_mismatch": "Invitation email does not match the account",
"identity.invitation_invalid": "Invitation is invalid or has expired",
"identity.invitation_account_exists": "An account with this address already exists. Sign in with it",
"identity.role_conflict": "User already has a different role in this organization",
"identity.token_required": "token is required",
"identity.totp_challenge_inactive": "TOTP challenge is not active",
@@ -86,6 +86,8 @@ MESSAGES: dict[str, object] = {
"emails.initial_access_subject": "Первичный доступ к Chatballs",
"emails.password_reset_body": "Здравствуйте, {name}.\n\nВы запросили сброс пароля для Chatballs. Чтобы задать новый пароль, перейдите по ссылке:\n{url}\n\nСсылка действует 30 минут. Если вы не запрашивали сброс, просто проигнорируйте это письмо.",
"emails.password_reset_subject": "Восстановление доступа к Chatballs",
"emails.membership_invitation_subject": "Приглашение в организацию {organization}",
"emails.membership_invitation_body": "Здравствуйте, {name}.\n\nВас приглашают в организацию «{organization}» в Chatballs. Чтобы принять приглашение, перейдите по ссылке:\n{url}\n\nСсылка действует 7 дней. Если вы не ожидали это письмо, просто проигнорируйте его.",
"identity.link_invalid_or_expired": "Ссылка недействительна или истекла",
"identity.password_needs_digit": "Пароль должен содержать цифру.",
"identity.password_needs_letter": "Пароль должен содержать букву.",
@@ -212,6 +214,9 @@ MESSAGES: dict[str, object] = {
"audit.action_identity_avatar_updated": "Изменено фото профиля",
"audit.action_identity_employee_blocked": "Сотрудник заблокирован",
"audit.action_identity_employee_created": "Добавлен сотрудник",
"audit.action_identity_employee_invited": "Отправлено приглашение сотруднику",
"audit.action_identity_invitation_accepted": "Принято приглашение в организацию",
"audit.action_identity_invitation_revoked": "Отозвано приглашение сотрудника",
"audit.action_identity_employee_groups_changed": "Изменены группы сотрудника",
"audit.action_identity_employee_password_reset": "Сброшен пароль сотрудника",
"audit.action_identity_employee_privileged_action_denied": "Отказано в привилегированном действии",
@@ -331,6 +336,7 @@ MESSAGES: dict[str, object] = {
"identity.invalid_totp_code": "Неверный код",
"identity.invitation_email_mismatch": "Приглашение выписано на другой адрес",
"identity.invitation_invalid": "Приглашение недействительно или истекло",
"identity.invitation_account_exists": "Учётная запись с этим адресом уже есть — войдите под ней",
"identity.role_conflict": "У пользователя уже другая роль в этой организации",
"identity.token_required": "Нужен токен",
"identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново",
+2 -2
View File
@@ -14,12 +14,12 @@ from chatballs.identity.models import (
@admin.register(HumanUser)
class HumanUserAdmin(UserAdmin):
ordering = ["email"]
list_display = ["email", "full_name", "is_active", "is_staff", "last_login"]
list_display = ["email", "full_name", "is_active", "is_staff", "is_instance_admin", "last_login"]
search_fields = ["email", "full_name"]
fieldsets = (
(None, {"fields": ("email", "password")}),
("Personal info", {"fields": ("full_name", "first_name", "last_name")}),
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "is_instance_admin", "groups", "user_permissions")}),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
add_fieldsets = (
@@ -78,6 +78,9 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
"identity.avatar_updated": "audit.action_identity_avatar_updated",
"identity.avatar_deleted": "audit.action_identity_avatar_deleted",
"identity.employee_created": "audit.action_identity_employee_created",
"identity.employee_invited": "audit.action_identity_employee_invited",
"identity.invitation_accepted": "audit.action_identity_invitation_accepted",
"identity.invitation_revoked": "audit.action_identity_invitation_revoked",
"identity.employee_updated": "audit.action_identity_employee_updated",
"identity.employee_blocked": "audit.action_identity_employee_blocked",
"identity.employee_unblocked": "audit.action_identity_employee_unblocked",
@@ -1,4 +1,8 @@
from chatballs.identity.auth.invitations import InvitationAcceptView
from chatballs.identity.auth.invitations import (
InvitationAcceptView,
InvitationPreviewView,
InvitationRegisterView,
)
from chatballs.identity.auth.password_reset import (
PasswordResetConfirmView,
PasswordResetRequestView,
@@ -40,4 +44,6 @@ __all__ = [
"TotpConfirmView",
"TotpVerifyView",
"InvitationAcceptView",
"InvitationPreviewView",
"InvitationRegisterView",
]
+29 -10
View File
@@ -1,32 +1,48 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from chatballs.i18n import current_language
from chatballs.i18n import current_language, t
from chatballs.identity.avatars import own_avatar_url
from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
from chatballs.identity.models import HumanUser, OrganizationMembership
from chatballs.identity.policy import get_effective_access
from chatballs.identity.sessions import revoke_user_sessions
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import membership_routes_for_user
def validation_response(error: ValidationError) -> Response:
"""Ошибки формы полями: мастер первого запуска и регистрация по приглашению."""
if hasattr(error, "message_dict"):
errors = {
key: messages[0] if isinstance(messages, list) else str(messages)
for key, messages in error.message_dict.items()
}
# validate_password кладёт сообщения без ключа поля.
if "__all__" in errors:
errors["password"] = " ".join(error.message_dict["__all__"])
del errors["__all__"]
detail = next(iter(errors.values()), t("setup.check_fields"))
return Response({"detail": detail, "errors": errors}, status=400)
message = " ".join(error.messages)
return Response({"detail": message, "errors": {"password": message}}, status=400)
def _user_payload(user: HumanUser) -> dict[str, object]:
memberships = []
routes = membership_routes_for_user(user.id)
organizations = Organization.objects.in_bulk(
[route.organization_id for route in routes]
)
for route in routes:
organization = organizations.get(route.organization_id)
if organization is None:
continue
with tenant_atomic(organization.id):
# Организация читается вместе с членством внутри её контекста: роль
# app не видит чужие строки организаций (tenancy/0033).
with tenant_atomic(route.organization_id):
membership = (
OrganizationMembership.objects.select_related("organization")
.filter(
id=route.resource_id,
user=user,
organization=organization,
organization_id=route.organization_id,
blocked_at__isnull=True,
)
.first()
@@ -70,6 +86,9 @@ def _user_payload(user: HumanUser) -> dict[str, object]:
"uiLanguage": user.ui_language,
"language": current_language(),
"avatarUrl": own_avatar_url(user),
# Администратор установки видит раздел «Платформа» и хранилище
# файлов: это свойства инсталляции, а не организации.
"isInstanceAdmin": user.is_instance_admin,
"memberships": memberships,
}
@@ -1,17 +1,87 @@
from __future__ import annotations
from rest_framework.permissions import IsAuthenticated
from django.contrib.auth import login
from django.core.exceptions import ValidationError
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from chatballs.i18n import t
from chatballs.identity.auth.common import _user_payload
from chatballs.identity.invitation_service import InvitationError, accept_invitation
from chatballs.identity.auth.common import _user_payload, validation_response
from chatballs.identity.invitation_service import (
InvitationError,
accept_invitation,
invitation_preview,
register_and_accept,
)
from chatballs.identity.sessions import remember_device
@method_decorator(ensure_csrf_cookie, name="dispatch")
class InvitationPreviewView(APIView):
"""Что стоит за ссылкой /join до входа: организация, адрес, есть ли учётная запись.
Ответ нужен экрану, чтобы решить, показать вход или форму создания пароля.
Токен — секрет из письма, поэтому подробности отдаются только по нему.
"""
authentication_classes: list = []
permission_classes = [AllowAny]
throttle_classes = [ScopedRateThrottle]
throttle_scope = "login"
def get(self, request: Request) -> Response:
token = str(request.query_params.get("token", "")).strip()
preview = invitation_preview(token) if token else None
if preview is None:
return Response({"valid": False})
return Response({"valid": True, **preview})
@method_decorator(csrf_protect, name="dispatch")
class InvitationRegisterView(APIView):
"""Создать учётную запись по приглашению, принять его и войти."""
authentication_classes: list = []
permission_classes = [AllowAny]
throttle_classes = [ScopedRateThrottle]
throttle_scope = "login"
def post(self, request: Request) -> Response:
body = request.data if isinstance(request.data, dict) else {}
token = str(body.get("token", "")).strip()
if not token:
return Response({"detail": t("identity.token_required")}, status=400)
try:
accepted = register_and_accept(
token=token,
full_name=str(body.get("fullName", "")),
password=str(body.get("password", "")),
)
except InvitationError as error:
return Response({"detail": str(error), "code": error.code}, status=400)
except ValidationError as error:
return validation_response(error)
user = accepted.membership.user
user.backend = "django.contrib.auth.backends.ModelBackend"
login(request, user)
remember_device(request)
return Response(
{
"authenticated": True,
"user": _user_payload(user),
"organizationPublicId": str(accepted.organization.public_id),
},
status=201,
)
class InvitationAcceptView(APIView):
"""Accept an OWNER invitation (SPEC-HUB-0021 §8.2).
"""Accept an organization invitation: OWNER (SPEC-HUB-0021 §8.2) or employee.
Authenticated endpoint: the caller must already have a HumanUser account
(created through sign-up / password setup). The token is read from the body;
@@ -26,7 +96,13 @@ class InvitationAcceptView(APIView):
if not token:
return Response({"detail": t("identity.token_required")}, status=400)
try:
accept_invitation(token=token, user=request.user)
accepted = accept_invitation(token=token, user=request.user)
except InvitationError as error:
return Response({"detail": str(error)}, status=400)
return Response({"user": _user_payload(request.user)})
return Response(
{
"user": _user_payload(request.user),
# Куда открыть приложение после принятия.
"organizationPublicId": str(accepted.organization.public_id),
}
)
@@ -23,4 +23,6 @@ urlpatterns = [
path("totp/confirm/", auth.TotpConfirmView.as_view(), name="auth-totp-confirm"),
path("totp/verify/", auth.TotpVerifyView.as_view(), name="auth-totp-verify"),
path("invitations/accept/", auth.InvitationAcceptView.as_view(), name="auth-invitation-accept"),
path("invitations/preview/", auth.InvitationPreviewView.as_view(), name="auth-invitation-preview"),
path("invitations/register/", auth.InvitationRegisterView.as_view(), name="auth-invitation-register"),
]
+5 -2
View File
@@ -38,9 +38,12 @@ def user_avatar_url(user: HumanUser | None, organization_public_id) -> str | Non
@functools.lru_cache(maxsize=4096)
def organization_public_id(organization_id: int) -> str:
"""public_id организации по id — неизменяем, поэтому кэшируется."""
from chatballs.identity.models import Organization
from chatballs.tenancy.ingress import organization_public_id_of
return str(Organization.objects.values_list("public_id", flat=True).get(pk=organization_id))
public_id = organization_public_id_of(organization_id)
if public_id is None:
raise LookupError(f"Organization {organization_id} does not exist")
return public_id
def user_avatar_url_in(user: HumanUser | None, organization_id: int) -> str | None:
+4 -2
View File
@@ -50,15 +50,17 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
"full_name": full_name,
"is_staff": True,
"is_superuser": True,
"is_instance_admin": True,
},
)
if created_owner:
owner.set_password(password)
owner.save(update_fields=["password"])
elif not owner.is_staff or not owner.is_superuser:
elif not owner.is_staff or not owner.is_superuser or not owner.is_instance_admin:
owner.is_staff = True
owner.is_superuser = True
owner.save(update_fields=["is_staff", "is_superuser"])
owner.is_instance_admin = True
owner.save(update_fields=["is_staff", "is_superuser", "is_instance_admin"])
OrganizationMembership.objects.get_or_create(
user=owner,
@@ -4,10 +4,8 @@ from chatballs.identity import (
administration_views,
demo_views,
group_views,
instance_views,
)
from chatballs.integrations import feature_views
from chatballs.tenancy import storage_views
urlpatterns = [
path("groups/", group_views.GroupListView.as_view(), name="group-list"),
@@ -17,16 +15,8 @@ urlpatterns = [
administration_views.OrganizationSettingsView.as_view(),
name="organization-settings",
),
path(
"administration/instance/",
instance_views.InstanceAddressView.as_view(),
name="instance-address",
),
path(
"administration/instance/email-check/",
instance_views.InstanceEmailCheckView.as_view(),
name="instance-email-check",
),
# Настройки установки (адрес, почта, TURN, хранилище) — не свойства
# организации: они живут на /api/v1/instance/ (identity.instance_urls).
path(
"administration/logo/",
administration_views.OrganizationLogoView.as_view(),
@@ -39,9 +29,6 @@ urlpatterns = [
),
path("demo/", demo_views.DemoDataView.as_view(), name="organization-demo-data"),
path("administration/communication/", feature_views.CommunicationSettingsView.as_view(), name="communication-settings"),
path("administration/storage/", storage_views.StorageSettingsView.as_view(), name="storage-settings"),
path("administration/storage/check/", storage_views.StorageCheckView.as_view(), name="storage-check"),
path("administration/storage/migrate/", storage_views.StorageMigrateView.as_view(), name="storage-migrate"),
path(
"administration/audit/",
administration_views.AuditListView.as_view(),
@@ -8,6 +8,7 @@ foundation → channels/ai → support → conversations → operations.
from __future__ import annotations
from chatballs.i18n import customer_language
from chatballs.identity.demo_seed.loaders import (
channels_ai as channels_ai_loader,
)
@@ -23,7 +24,6 @@ from chatballs.identity.demo_seed.loaders import (
from chatballs.identity.demo_seed.loaders import (
support as support_loader,
)
from chatballs.i18n import customer_language
from chatballs.identity.demo_seed.refs import DemoRefs
from chatballs.tenancy.context import TenantContext
+35
View File
@@ -12,6 +12,7 @@ from chatballs.identity.instance_settings import (
email_from_address,
public_base_url,
)
from chatballs.identity.invitation_models import OrganizationInvitation
from chatballs.identity.models import HumanUser
@@ -60,6 +61,40 @@ def send_initial_access_email(user: HumanUser) -> None:
)
def send_membership_invitation_email(
invitation: OrganizationInvitation, token: str, user: HumanUser | None = None
) -> None:
"""Приглашение в организацию по ссылке /join.
Существующая учётная запись входит под своим паролем, новая создаёт его
по той же ссылке. Язык — получателя, если он известен, дальше
приглашающей организации.
"""
join_url = f"{public_base_url()}/join?token={token}"
language = resolve_language(
user_language=user.ui_language if user else "",
organization_language=invitation.organization.language,
instance_language=default_language(),
)
with translation.override(language):
send_mail(
subject=t(
"emails.membership_invitation_subject",
organization=invitation.organization.name,
),
message=t(
"emails.membership_invitation_body",
name=(user.full_name if user else "") or invitation.email,
organization=invitation.organization.name,
url=join_url,
),
from_email=email_from_address(),
recipient_list=[invitation.email],
connection=email_connection(),
)
def send_password_reset_email(user: HumanUser) -> None:
reset_url = _password_setup_url(user)
with translation.override(_recipient_language(user)):
@@ -0,0 +1,159 @@
"""Ожидающие приглашения в списке сотрудников (кадры E1/E2, статус «Приглашён»).
Приглашение существующей учётной записи ещё не членство, но администратор
должен его видеть там же, где сотрудников: строкой с той же ролью, должностью
и группами, что придут после принятия. Отсюда же приглашение отправляют ещё
раз или отзывают.
"""
from __future__ import annotations
from django.utils import timezone
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.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.avatars import user_avatar_url
from chatballs.identity.employee_selectors import ROLE_ANY
from chatballs.identity.governance import can_create_role
from chatballs.identity.group_models import EmployeeGroup
from chatballs.identity.invitation_models import OrganizationInvitation
from chatballs.identity.invitation_service import (
MEMBERSHIP_INVITATION_REQUESTED,
MEMBERSHIP_INVITATION_TTL,
)
from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
from chatballs.identity.policy import has_capability_any_scope
def _pending(organization_id: int):
return OrganizationInvitation.objects.filter(
organization_id=organization_id,
accepted_at__isnull=True,
revoked_at__isnull=True,
expires_at__gt=timezone.now(),
)
def pending_invitations_payload(organization: Organization, params) -> list[dict[str, object]]:
"""Строки приглашений с теми же фильтрами, что у списка сотрудников."""
invitations = list(_pending(organization.id).order_by("email"))
if not invitations:
return []
users = {
user.email.lower(): user
for user in HumanUser.objects.filter(
email__in=[invitation.email for invitation in invitations]
)
}
group_ids = {gid for invitation in invitations for gid in invitation.group_ids}
groups = {
group.id: group
for group in EmployeeGroup.objects.filter(organization=organization, id__in=group_ids)
}
role = params.get("role")
group_filter = params.get("group")
query = params.get("q", "").strip().lower()
rows: list[dict[str, object]] = []
for invitation in invitations:
user = users.get(invitation.email.lower())
if user is None:
continue
if role and role != ROLE_ANY and invitation.role != role:
continue
if group_filter and group_filter != ROLE_ANY and str(group_filter).isdigit():
if int(group_filter) not in invitation.group_ids:
continue
haystack = f"{user.full_name} {user.email} {invitation.position_title}".lower()
if query and query not in haystack:
continue
rows.append(
{
"id": invitation.id,
"email": user.email,
"fullName": user.full_name,
"avatarUrl": user_avatar_url(user, organization.public_id),
"role": invitation.role,
"positionTitle": invitation.position_title,
"phone": invitation.phone,
"groups": [
{"id": gid, "name": groups[gid].name}
for gid in invitation.group_ids
if gid in groups
],
"invitedAt": invitation.created_at.isoformat(),
"expiresAt": invitation.expires_at.isoformat(),
}
)
return rows
def _load_for_action(request: Request, invitation_id: int) -> OrganizationInvitation | Response:
actor: OrganizationMembership = request.tenant_context.membership
if not has_capability_any_scope(actor, "employees.manage"):
return Response({"detail": t("admin.not_allowed")}, status=403)
invitation = _pending(actor.organization_id).filter(pk=invitation_id).first()
if invitation is None:
return Response({"detail": t("identity.invitation_invalid")}, status=404)
# Приглашение с ролью, которую актор выдать не вправе, ему и не отозвать.
if not can_create_role(actor, invitation.role):
return Response({"detail": t("admin.not_allowed")}, status=403)
return invitation
class InvitationResendView(APIView):
"""Отправить письмо ещё раз: срок продлевается, токен выпускает воркер."""
permission_classes = [IsAuthenticated]
def post(self, request: Request, invitation_id: int) -> Response:
loaded = _load_for_action(request, invitation_id)
if isinstance(loaded, Response):
return loaded
loaded.expires_at = timezone.now() + MEMBERSHIP_INVITATION_TTL
loaded.save(update_fields=["expires_at"])
record_audit_event(
action="identity.employee_invited",
actor=request.user,
organization=loaded.organization,
object_type="OrganizationInvitation",
object_id=str(loaded.id),
payload={"role": loaded.role, "resend": True},
request=request,
)
enqueue_event(
DomainEvent(
aggregate_type="OrganizationInvitation",
aggregate_id=str(loaded.id),
event_type=MEMBERSHIP_INVITATION_REQUESTED,
payload={"invitationId": loaded.id},
tenant_context=request.tenant_context,
)
)
return Response({"ok": True})
class InvitationRevokeView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request, invitation_id: int) -> Response:
loaded = _load_for_action(request, invitation_id)
if isinstance(loaded, Response):
return loaded
loaded.revoked_at = timezone.now()
loaded.save(update_fields=["revoked_at"])
record_audit_event(
action="identity.invitation_revoked",
actor=request.user,
organization=loaded.organization,
object_type="OrganizationInvitation",
object_id=str(loaded.id),
payload={"role": loaded.role},
request=request,
)
return Response({"ok": True})
@@ -1,6 +1,7 @@
from django.urls import path
from chatballs.identity import (
employee_invitations,
employee_security_views,
employee_views,
ownership_views,
@@ -11,6 +12,9 @@ urlpatterns = [
# Создание сотрудника (ADMIN/EMPLOYEE по policy). Путь operators/ сохранён для
# обратной совместимости; поле role в теле выбирает системную роль.
path("operators/", employee_views.EmployeeCreateView.as_view(), name="employee-create"),
# Ожидающие приглашения существующих учётных записей (статус «Приглашён»).
path("invitations/<int:invitation_id>/resend/", employee_invitations.InvitationResendView.as_view(), name="employee-invitation-resend"),
path("invitations/<int:invitation_id>/revoke/", employee_invitations.InvitationRevokeView.as_view(), name="employee-invitation-revoke"),
path("<int:user_id>/", employee_views.EmployeeDetailView.as_view(), name="employee-detail"),
path("<int:user_id>/avatar/", employee_views.EmployeeAvatarView.as_view(), name="employee-avatar"),
path("<int:user_id>/update/", employee_views.EmployeeUpdateView.as_view(), name="employee-update"),
@@ -9,6 +9,7 @@ from chatballs.api.pagination import page_payload, paginate
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.employee_invitations import pending_invitations_payload
from chatballs.identity.employee_password import clean_password_mode, issue_initial_password
from chatballs.identity.employee_selectors import employees_for
from chatballs.identity.employee_support import employee_payload, get_owned_profile
@@ -21,6 +22,7 @@ from chatballs.identity.employee_validation import (
from chatballs.identity.event_handlers import INITIAL_ACCESS_REQUESTED
from chatballs.identity.governance import EmployeeAction, can_create_role, can_manage_employee
from chatballs.identity.group_models import EmployeeGroupMember
from chatballs.identity.invitation_service import invite_existing_user
from chatballs.identity.models import EmployeeRole, HumanUser, OrganizationMembership
from chatballs.identity.policy import has_capability_any_scope
@@ -72,7 +74,13 @@ class EmployeeListView(APIView):
employees_for(actor.organization_id, request.query_params),
request.query_params,
)
return Response(page_payload(page, lambda employee: employee_payload(employee, actor)))
payload = page_payload(page, lambda employee: employee_payload(employee, actor))
# Ожидающие приглашения — строками со статусом «Приглашён»: их мало,
# они не листаются и показываются на первой странице.
payload["invitations"] = pending_invitations_payload(
actor.organization, request.query_params
)
return Response(payload)
class EmployeeCreateView(APIView):
@@ -107,13 +115,33 @@ class EmployeeCreateView(APIView):
return Response({"detail": t("admin.temporary_passwords_unsupported")}, status=400)
if password_mode is None:
return Response({"detail": t("admin.unknown_password_mode")}, status=400)
if HumanUser.objects.filter(email=email).exists():
return Response({"detail": t("admin.email_taken")}, status=400)
groups, groups_error = resolve_groups(actor.organization, body.get("groupIds"))
if groups_error:
return Response({"detail": groups_error}, status=400)
existing = HumanUser.objects.filter(email=email).first()
if existing is not None:
# Учётная запись глобальная, а вход в организацию — по согласию
# человека: вместо «e-mail занят» уходит приглашение с той же ролью
# и должностью, членство появится после его принятия. В своей
# организации адрес и правда занят.
if not existing.is_active or OrganizationMembership.objects.filter(
user=existing, organization=actor.organization
).exists():
return Response({"detail": t("admin.email_taken")}, status=400)
invite_existing_user(
organization=actor.organization,
user=existing,
role=requested_role,
position_title=position_title,
phone=phone,
groups=groups or [],
created_by=actor,
context=request.tenant_context,
request=request,
)
return Response({"employee": None, "password": None, "invited": True}, status=201)
user = HumanUser.objects.create_user(
email=email,
password=None,
@@ -1,5 +1,15 @@
from chatballs.events.handlers import register
from chatballs.identity.emails import send_initial_access_email, send_password_reset_email
from chatballs.identity.emails import (
send_initial_access_email,
send_membership_invitation_email,
send_password_reset_email,
)
from chatballs.identity.invitation_models import OrganizationInvitation
from chatballs.identity.invitation_service import (
MEMBERSHIP_INVITATION_REQUESTED,
OWNER_INVITATION_REQUESTED,
refresh_invitation_token,
)
from chatballs.identity.models import HumanUser
from chatballs.tenancy.context import TenantContext
@@ -39,6 +49,37 @@ def handle_password_reset_requested(payload: dict, context: TenantContext | None
send_password_reset_email(user)
def _send_invitation(payload: dict, context: TenantContext | None, *, require_account: bool) -> None:
if context is None:
raise ValueError("Invitation is a tenant event")
invitation = (
OrganizationInvitation.objects.select_related("organization")
.filter(pk=payload.get("invitationId"), organization_id=context.organization_id)
.first()
)
if invitation is None or not invitation.is_pending:
return
user = HumanUser.objects.filter(email__iexact=invitation.email, is_active=True).first()
if user is None and require_account:
return
# Токен выпускается здесь, в момент отправки: открытый токен нигде не
# хранится, а письмо уходит позже, чем приглашение выписано.
token = refresh_invitation_token(invitation)
send_membership_invitation_email(invitation, token, user)
@register(MEMBERSHIP_INVITATION_REQUESTED)
def handle_membership_invitation_requested(payload: dict, context: TenantContext | None) -> None:
# Сотрудника приглашают только с существующей учётной записью.
_send_invitation(payload, context, require_account=True)
@register(OWNER_INVITATION_REQUESTED)
def handle_owner_invitation_requested(payload: dict, context: TenantContext | None) -> None:
# Владельца из провижининга учётная запись может ждать: он создаст её по ссылке.
_send_invitation(payload, context, require_account=False)
# --- Демо-данные (мастер первого запуска и «Настройки») -----------------------
DEMO_INSTALL_REQUESTED = "demo.install_requested"
@@ -0,0 +1,77 @@
"""Доступ к настройкам установки: адрес, почта, TURN, хранилище файлов.
Это свойства инсталляции, а не организации, поэтому право их менять не
выводится из роли в организации: владелец одной организации не должен
переключать SMTP или бакет, общие для всех. Менять их может только
администратор установки — глобальный признак на учётной записи
(``HumanUser.is_instance_admin``). Первым его получает владелец из мастера
первого запуска; дальше признак передают командой ``set_instance_admin``.
Читать настройки без секретов может тот, кто видит «Настройки» хотя бы в одной
организации: карточка relay для звонков показывает адреса TURN администратору
организации, менять их он не может.
"""
from __future__ import annotations
from rest_framework.permissions import SAFE_METHODS, BasePermission
from rest_framework.request import Request
from rest_framework.views import APIView
from chatballs.identity.models import HumanUser, OrganizationMembership
from chatballs.identity.policy import has_capability_any_scope
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import membership_routes_for_user
INSTANCE_SETTINGS_VIEW_CAPABILITY = "settings.view"
def is_instance_admin(user: object) -> bool:
return (
isinstance(user, HumanUser)
and user.is_active
and bool(getattr(user, "is_instance_admin", False))
)
def can_view_instance_settings(user: object) -> bool:
"""Администратор установки или менеджер хотя бы одной организации.
Членства — тенантные строки: каждое читается в контексте своей организации,
как при сборке сессии.
"""
if is_instance_admin(user):
return True
if not isinstance(user, HumanUser) or not user.is_active:
return False
for route in membership_routes_for_user(user.id):
with tenant_atomic(route.organization_id):
membership = (
OrganizationMembership.objects.select_related("user")
.filter(id=route.resource_id, user=user, blocked_at__isnull=True)
.first()
)
if membership is not None and has_capability_any_scope(
membership, INSTANCE_SETTINGS_VIEW_CAPABILITY
):
return True
return False
class InstanceSettingsPermission(BasePermission):
"""GET — менеджеру любой организации, изменения — администратору установки."""
message = "Instance administrator rights are required"
def has_permission(self, request: Request, view: APIView) -> bool:
if request.method in SAFE_METHODS:
return can_view_instance_settings(request.user)
return is_instance_admin(request.user)
class IsInstanceAdmin(BasePermission):
message = "Instance administrator rights are required"
def has_permission(self, request: Request, view: APIView) -> bool:
return is_instance_admin(request.user)
@@ -0,0 +1,28 @@
"""Настройки установки: путь без организации в адресе.
Адрес, почта, TURN и хранилище файлов общие для всех организаций, поэтому
они не живут под ``/api/v1/organizations/<uuid>/`` и не проходят tenant
middleware. Доступ — по признаку администратора установки
(см. ``identity.instance_access``).
"""
from django.urls import path
from chatballs.identity import instance_views
from chatballs.tenancy import storage_views
urlpatterns = [
path("settings/", instance_views.InstanceAddressView.as_view(), name="instance-settings"),
path(
"settings/email-check/",
instance_views.InstanceEmailCheckView.as_view(),
name="instance-email-check",
),
path("storage/", storage_views.StorageSettingsView.as_view(), name="instance-storage"),
path("storage/check/", storage_views.StorageCheckView.as_view(), name="instance-storage-check"),
path(
"storage/migrate/",
storage_views.StorageMigrateView.as_view(),
name="instance-storage-migrate",
),
]
@@ -12,9 +12,9 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.i18n import t
from chatballs.i18n.languages import DEFAULT_LANGUAGE, LANGUAGES, normalize_language
from chatballs.identity.instance_access import InstanceSettingsPermission, IsInstanceAdmin
from chatballs.identity.instance_settings import (
InstanceSettings,
email_connection,
@@ -58,8 +58,9 @@ def instance_payload(row: InstanceSettings) -> dict:
class InstanceAddressView(APIView):
permission_classes = [HasCapability]
required_capabilities = {"GET": "settings.view", "PATCH": "company.manage"}
# Путь без организации: читает менеджер любой организации, меняет только
# администратор установки (identity.instance_access).
permission_classes = [InstanceSettingsPermission]
def get(self, request: Request) -> Response:
return Response({"instance": instance_payload(InstanceSettings.load())})
@@ -171,8 +172,7 @@ class InstanceEmailCheckView(APIView):
получил приглашение.
"""
permission_classes = [HasCapability]
required_capability = "company.manage"
permission_classes = [IsInstanceAdmin]
def post(self, request: Request) -> Response:
from django.core.mail import send_mail
@@ -19,6 +19,12 @@ class OrganizationInvitation(models.Model):
)
email = models.EmailField()
role = models.CharField(max_length=32, choices=EmployeeRole.choices)
# Поля будущего членства: приглашение существующего пользователя несёт
# то же, что форма создания сотрудника, а членство собирается из них при
# принятии. Группы — по id: к моменту принятия часть могла исчезнуть.
position_title = models.CharField(max_length=120, blank=True, default="")
phone = models.CharField(max_length=32, blank=True, default="")
group_ids = models.JSONField(default=list, blank=True)
token_hash = models.CharField(max_length=128, unique=True)
expires_at = models.DateTimeField()
created_by = models.ForeignKey(
@@ -3,13 +3,18 @@ from __future__ import annotations
import hashlib
import secrets
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timedelta
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.i18n.audience import customer_language
from chatballs.identity.audit import record_audit_event
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
@@ -18,8 +23,17 @@ from chatballs.identity.models import (
OrganizationMembership,
OrganizationStatus,
)
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
# Приглашение существующего пользователя в организацию: письмо отправляет
# воркер по этому событию (identity.event_handlers).
MEMBERSHIP_INVITATION_REQUESTED = "identity.membership_invitation_requested"
# Приглашение владельца из платформенного провижининга (SPEC-HUB-0021 §8.2):
# учётной записи может ещё не быть, тогда человек создаёт её по ссылке.
OWNER_INVITATION_REQUESTED = "organization.owner_invitation_requested"
MEMBERSHIP_INVITATION_TTL = timedelta(days=7)
def _token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@@ -54,15 +68,19 @@ def issue_invitation(
role: str,
expires_at: datetime,
created_by: OrganizationMembership | None,
position_title: str = "",
phone: str = "",
group_ids: list[int] | None = None,
) -> IssuedInvitation:
normalized_email = email.strip().lower()
now = timezone.now()
# Повторное приглашение на тот же адрес заменяет прежнее: старое письмо
# перестаёт работать, а не живёт параллельно с новым.
OrganizationInvitation.objects.filter(
organization=organization,
email__iexact=normalized_email,
accepted_at__isnull=True,
revoked_at__isnull=True,
expires_at__lte=now,
).update(revoked_at=now)
token = secrets.token_urlsafe(32)
invitation = OrganizationInvitation.objects.create(
@@ -72,10 +90,119 @@ def issue_invitation(
token_hash=_token_hash(token),
expires_at=expires_at,
created_by=created_by,
position_title=position_title,
phone=phone,
group_ids=list(group_ids or []),
)
return IssuedInvitation(invitation=invitation, token=token)
def refresh_invitation_token(invitation: OrganizationInvitation) -> str:
"""Новый токен взамен прежнего.
Открытый токен нигде не хранится, а письмо уходит из воркера позже, чем
приглашение выписано, — поэтому воркер выпускает токен сам, в момент
отправки, и старый перестаёт действовать.
"""
token = secrets.token_urlsafe(32)
invitation.token_hash = _token_hash(token)
invitation.save(update_fields=["token_hash"])
return token
def invite_existing_user(
*,
organization: Organization,
user: HumanUser,
role: str,
position_title: str,
phone: str,
groups: list[EmployeeGroup],
created_by: OrganizationMembership | None,
context: TenantContext,
request=None,
) -> OrganizationInvitation:
"""Пригласить уже существующую учётную запись в организацию.
Учётная запись глобальная, а членство появляется только с согласия
человека: он получает письмо и принимает приглашение под своим входом.
"""
issued = issue_invitation(
organization=organization,
email=user.email,
role=role,
expires_at=timezone.now() + MEMBERSHIP_INVITATION_TTL,
created_by=created_by,
position_title=position_title,
phone=phone,
group_ids=[group.id for group in groups],
)
record_audit_event(
action="identity.employee_invited",
actor=context.actor_user,
organization=organization,
object_type="OrganizationInvitation",
object_id=str(issued.invitation.id),
payload={"role": role},
request=request,
)
enqueue_event(
DomainEvent(
aggregate_type="OrganizationInvitation",
aggregate_id=str(issued.invitation.id),
event_type=MEMBERSHIP_INVITATION_REQUESTED,
payload={"invitationId": issued.invitation.id},
tenant_context=context,
)
)
return issued.invitation
def invitation_preview(token: str) -> dict[str, object] | None:
"""Что видит человек по ссылке до входа: куда зовут и есть ли учётная запись."""
invitation = pending_invitation_for_token(token)
if invitation is None:
return None
return {
"email": invitation.email,
"organizationName": invitation.organization.name,
"accountExists": HumanUser.objects.filter(email__iexact=invitation.email).exists(),
}
@transaction.atomic
def register_and_accept(*, token: str, full_name: str, password: str) -> AcceptedInvitation:
"""Создать учётную запись по приглашению и сразу принять его.
Только для адреса без учётной записи: у существующей есть пароль, и
приглашение принимается после входа. Пароль проверяется теми же
правилами, что в мастере первого запуска.
"""
invitation = pending_invitation_for_token(token)
if invitation is None:
raise InvitationError(t("identity.invitation_invalid"), code="invitation_invalid")
if HumanUser.objects.filter(email__iexact=invitation.email).exists():
raise InvitationError(t("identity.invitation_account_exists"), code="account_exists")
name = " ".join(full_name.split())
if not name:
raise ValidationError({"fullName": t("setup.your_name_required")})
probe = HumanUser(email=invitation.email, full_name=name)
validate_password(password, user=probe)
user = HumanUser.objects.create_user(
email=invitation.email,
password=password,
full_name=name,
is_staff=False,
is_superuser=False,
must_change_password=False,
)
return accept_invitation(token=token, user=user)
def pending_invitation_for_token(token: str) -> OrganizationInvitation | None:
if not token:
return None
@@ -89,10 +216,11 @@ def pending_invitation_for_token(token: str) -> OrganizationInvitation | None:
@transaction.atomic
def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
"""Accept an OWNER invitation and activate the organization (SPEC-HUB-0021 §8.2).
"""Принять приглашение: владельца (SPEC-HUB-0021 §8.2) или сотрудника.
Idempotent: re-accepting the same token does not create a second membership
or usage period. Requires an authenticated HumanUser with a matching email.
Членство собирается из полей приглашения; для владельца организация ещё
и активируется. Идемпотентно: повторное принятие того же токена не создаёт
второго членства. Нужна учётная запись с тем же e-mail.
"""
invitation = pending_invitation_for_token(token)
if invitation is None:
@@ -110,13 +238,17 @@ def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
organization = invitation.organization
with tenant_atomic(organization.id):
membership = _ensure_owner_membership(organization, user)
membership = _ensure_membership(organization, user, invitation)
if invitation.role == EmployeeRole.OWNER:
_activate_organization(organization, user, membership)
invitation.accepted_at = timezone.now()
invitation.save(update_fields=["accepted_at"])
record_audit_event(
action="organization.owner_activated",
action=(
"organization.owner_activated"
if invitation.role == EmployeeRole.OWNER
else "identity.invitation_accepted"
),
actor=user,
organization=organization,
object_type="OrganizationInvitation",
@@ -128,25 +260,42 @@ def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
)
def _ensure_owner_membership(
organization: Organization, user: HumanUser
def _ensure_membership(
organization: Organization, user: HumanUser, invitation: OrganizationInvitation
) -> OrganizationMembership:
membership, _ = OrganizationMembership.objects.get_or_create(
position_title = invitation.position_title
if not position_title and invitation.role == EmployeeRole.OWNER:
position_title = t("setup.owner_position", language=customer_language(organization))
membership, created = OrganizationMembership.objects.get_or_create(
user=user,
organization=organization,
defaults={
"role": EmployeeRole.OWNER,
"position_title": "Владелец",
"role": invitation.role,
"position_title": position_title,
"phone": invitation.phone,
"totp_required": False,
},
)
if membership.role != EmployeeRole.OWNER:
# An existing non-OWNER membership for this user should not be silently
# promoted by an invitation; surface as a conflict instead.
if membership.role != invitation.role:
# Уже существующее членство с другой ролью приглашение молча не меняет:
# это конфликт, а не повышение.
raise InvitationError(
t("identity.role_conflict"),
code="role_conflict",
)
if created and invitation.group_ids:
# Группы, которые к моменту принятия ещё существуют.
groups = EmployeeGroup.objects.filter(
organization=organization, id__in=list(invitation.group_ids)
)
EmployeeGroupMember.objects.bulk_create(
[
EmployeeGroupMember(
organization_id=organization.id, group=group, employee=membership
)
for group in groups
]
)
return membership
@@ -11,9 +11,9 @@ from django.core.management.base import BaseCommand, CommandError
from chatballs.identity.demo_models import DemoDataset, DemoDatasetStatus
from chatballs.identity.demo_seed import service
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.lookup import organization_by_slug
class Command(BaseCommand):
@@ -26,10 +26,9 @@ class Command(BaseCommand):
group.add_argument("--remove", action="store_true", help="Remove the installed demo dataset")
def handle(self, *args: object, **options: object) -> None:
try:
organization = Organization.objects.get(slug=options["organization"])
except Organization.DoesNotExist as error:
raise CommandError(f"Organization {options['organization']!r} not found") from error
organization = organization_by_slug(str(options["organization"]))
if organization is None:
raise CommandError(f"Organization {options['organization']!r} not found")
context = TenantContext.for_resource(organization, actor_kind=TenantActorKind.SYSTEM)
if not options["apply"] and not options["remove"]:
@@ -0,0 +1,37 @@
from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
from chatballs.identity.models import HumanUser
class Command(BaseCommand):
help = (
"Grants or revokes the installation administrator flag for an existing "
"account. The flag controls installation-wide settings (address, mail, "
"TURN, file storage) and is not derived from any organization role. "
"Passwords and memberships are not touched."
)
def add_arguments(self, parser) -> None:
parser.add_argument("--email", required=True, help="Account e-mail")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--grant", action="store_true", help="Make the account an installation administrator")
group.add_argument("--revoke", action="store_true", help="Remove the installation administrator flag")
def handle(self, *args: object, **options: object) -> None:
email = HumanUser.objects.normalize_email(str(options["email"])).lower()
user = HumanUser.objects.filter(email__iexact=email).first()
if user is None:
raise CommandError(f"Account {email!r} not found")
grant = bool(options["grant"])
if grant and not user.is_active:
raise CommandError(f"Account {email!r} is inactive")
if not grant and not HumanUser.objects.filter(is_instance_admin=True, is_active=True).exclude(pk=user.pk).exists():
raise CommandError("Cannot revoke the last active installation administrator")
if user.is_instance_admin == grant:
self.stdout.write(f"{email}: already {'an' if grant else 'not an'} installation administrator")
return
user.is_instance_admin = grant
user.save(update_fields=["is_instance_admin"])
self.stdout.write(self.style.SUCCESS(f"{email}: installation administrator {'granted' if grant else 'revoked'}"))
@@ -0,0 +1,28 @@
# Администратор установки — глобальный признак учётной записи, а не роль в
# организации: настройки инсталляции (адрес, почта, TURN, хранилище) общие для
# всех организаций, и менять их вправе не любой владелец организации.
#
# Существующие установки: признак получают те, кому мастер первого запуска
# (или bootstrap локального контура) выдал is_superuser — это и есть владелец
# установки. Провижининг через платформенный API суперпользователей не создаёт.
from django.db import migrations, models
def grant_to_setup_owners(apps, schema_editor):
HumanUser = apps.get_model("identity", "HumanUser")
HumanUser.objects.filter(is_superuser=True).update(is_instance_admin=True)
class Migration(migrations.Migration):
dependencies = [
("identity", "0035_language_settings"),
]
operations = [
migrations.AddField(
model_name="humanuser",
name="is_instance_admin",
field=models.BooleanField(default=False),
),
migrations.RunPython(grant_to_setup_owners, migrations.RunPython.noop),
]
@@ -0,0 +1,29 @@
# Приглашение существующего пользователя во вторую организацию: вместо ошибки
# «e-mail занят» администратор выписывает приглашение с той же ролью,
# должностью, телефоном и группами, что и при создании сотрудника. Членство
# создаётся из этих полей в момент принятия, а не в момент приглашения.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("identity", "0036_humanuser_is_instance_admin"),
]
operations = [
migrations.AddField(
model_name="organizationinvitation",
name="position_title",
field=models.CharField(blank=True, default="", max_length=120),
),
migrations.AddField(
model_name="organizationinvitation",
name="phone",
field=models.CharField(blank=True, default="", max_length=32),
),
migrations.AddField(
model_name="organizationinvitation",
name="group_ids",
field=models.JSONField(blank=True, default=list),
),
]
@@ -84,6 +84,11 @@ class HumanUser(AbstractUser):
# русский, а отсутствие личного выбора, и она переживает смену языка
# организации, тогда как записанный при регистрации код — нет.
ui_language = models.CharField(max_length=5, blank=True, default="")
# Администратор установки: право менять свойства инсталляции — адрес,
# почту, TURN, хранилище файлов. Это не роль в организации, а признак
# учётной записи: первым его получает владелец из мастера первого
# запуска, дальше его передают командой set_instance_admin.
is_instance_admin = models.BooleanField(default=False)
# Фото сотрудника (дизайн-базлайн v2): видно коллегам в сайдбаре, подписи
# сообщений, выборе ответственного. Загружается в профиле.
avatar = models.FileField(upload_to=user_avatar_upload_path, storage=user_storage, max_length=512, blank=True, default="")
+27 -21
View File
@@ -5,16 +5,16 @@
Никаких параметров в .env и CLI: всё задаёт человек в браузере. После
создания владельца мастер закрывается навсегда (409).
Запись идёт на соединении ``platform`` — единственной runtime-роли с правом
создавать организации (SPEC-HUB-0021 §10); RLS-контекст и транзакция живут
на том же соединении.
Запись идёт по основному соединению процесса. Роль app вправе вставить
организацию только пока их нет (политика tenancy/0032); дальше создавать
организации может только роль platform (SPEC-HUB-0021 §10). Так пароль
platform-роли не нужен процессу backend-app.
"""
from __future__ import annotations
from dataclasses import dataclass
from django.conf import settings
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
@@ -35,14 +35,15 @@ from chatballs.identity.models import (
)
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.routing import use_database
from chatballs.tenancy.ingress import organization_route_by_slug
from chatballs.tenancy.lookup import instance_has_organizations, reserve_organization_id
ORGANIZATION_NAME_MAX_LENGTH = 255
FULL_NAME_MAX_LENGTH = 255
# Алиас соединения для операций уровня инстанса. В тестах оба алиаса —
# зеркала одной тестовой БД под ролью-владельцем кластера.
INSTANCE_DB_ALIAS = "default" if settings.TESTING else "platform"
# Мастер работает по основному соединению процесса: отдельный алиас с ролью
# platform ему больше не нужен.
INSTANCE_DB_ALIAS = "default"
class SetupAlreadyCompleted(Exception):
@@ -70,11 +71,10 @@ class SetupResult:
def instance_needs_setup() -> bool:
"""Мастер нужен, пока не создана ни одна организация.
Организации видны роли app целиком (RLS SELECT USING true), поэтому
проверка не требует tenant-контекста.
Строки организаций роли app без контекста не видны (tenancy/0033):
наличие хотя бы одной проверяет SECURITY DEFINER-функция.
"""
with use_database(INSTANCE_DB_ALIAS):
return not Organization.objects.exists()
return not instance_has_organizations()
def _clean(data: SetupInput) -> SetupInput:
@@ -110,7 +110,7 @@ def _unique_slug(name: str) -> str:
base = slugify(name)[:40].strip("-") or "organization"
candidate = base
suffix = 2
while Organization.objects.filter(slug=candidate).exists():
while organization_route_by_slug(candidate) is not None:
candidate = f"{base}-{suffix}"
suffix += 1
return candidate
@@ -138,7 +138,7 @@ def _complete_setup(
data: SetupInput, language: str, public_host: str, public_scheme: str
) -> SetupResult:
clean = _clean(data)
with use_database(INSTANCE_DB_ALIAS), transaction.atomic(using=INSTANCE_DB_ALIAS):
with transaction.atomic(using=INSTANCE_DB_ALIAS):
# Адрес, на котором человек прошёл мастер, и есть публичный адрес
# установки: другого источника у коробки нет.
if public_host:
@@ -148,7 +148,7 @@ def _complete_setup(
# увидит созданную организацию.
with connections[INSTANCE_DB_ALIAS].cursor() as cursor:
cursor.execute("SELECT pg_advisory_xact_lock(hashtext('chatballs.instance_setup'))")
if Organization.objects.exists():
if instance_has_organizations(using=INSTANCE_DB_ALIAS):
raise SetupAlreadyCompleted()
# Пароль проверяется против атрибутов будущего пользователя (схожесть
@@ -156,12 +156,17 @@ def _complete_setup(
probe = HumanUser(email=clean.email, full_name=clean.full_name)
validate_password(clean.password, user=probe)
organization = Organization.objects.create(
name=clean.organization_name,
slug=_unique_slug(clean.organization_name),
status=OrganizationStatus.ACTIVE,
)
with tenant_atomic(organization.id, using=INSTANCE_DB_ALIAS):
# id выделяется заранее: строка организации видна роли app только в
# её контексте, и вставка идёт уже внутри него (tenancy/0033).
organization_id = reserve_organization_id(using=INSTANCE_DB_ALIAS)
with tenant_atomic(organization_id, using=INSTANCE_DB_ALIAS):
organization = Organization(
id=organization_id,
name=clean.organization_name,
slug=_unique_slug(clean.organization_name),
status=OrganizationStatus.ACTIVE,
)
organization.save(force_insert=True)
ensure_uncategorized_category(organization)
owner = HumanUser.objects.create_user(
email=clean.email,
@@ -169,6 +174,7 @@ def _complete_setup(
full_name=clean.full_name,
is_staff=True,
is_superuser=True,
is_instance_admin=True,
)
OrganizationMembership.objects.create(
user=owner,
+2 -18
View File
@@ -9,7 +9,7 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from chatballs.i18n import t
from chatballs.identity.auth.common import _user_payload
from chatballs.identity.auth.common import _user_payload, validation_response
from chatballs.identity.setup import (
SetupAlreadyCompleted,
SetupInput,
@@ -23,22 +23,6 @@ def setup_closed() -> dict[str, str]:
return {"detail": t("identity.setup_already_done")}
def _validation_response(error: ValidationError) -> Response:
if hasattr(error, "message_dict"):
errors = {
key: messages[0] if isinstance(messages, list) else str(messages)
for key, messages in error.message_dict.items()
}
# validate_password кладёт сообщения без ключа поля.
if "__all__" in errors:
errors["password"] = " ".join(error.message_dict["__all__"])
del errors["__all__"]
detail = next(iter(errors.values()), t("setup.check_fields"))
return Response({"detail": detail, "errors": errors}, status=400)
message = " ".join(error.messages)
return Response({"detail": message, "errors": {"password": message}}, status=400)
@method_decorator(ensure_csrf_cookie, name="dispatch")
class SetupStatusView(APIView):
authentication_classes: list = []
@@ -75,7 +59,7 @@ class SetupView(APIView):
except SetupAlreadyCompleted:
return Response(setup_closed(), status=409)
except ValidationError as error:
return _validation_response(error)
return validation_response(error)
owner = result.owner
owner.backend = "django.contrib.auth.backends.ModelBackend"
login(request, owner)
@@ -370,7 +370,7 @@ class InstanceAddressTests(TestCase):
def test_owner_sets_domain_and_scheme(self) -> None:
response = self.client.patch(
"/api/v1/company/administration/instance/",
"/api/v1/instance/settings/",
{"publicHost": "crm.example.com", "publicScheme": "https"},
format="json",
)
@@ -383,7 +383,7 @@ class InstanceAddressTests(TestCase):
def test_address_may_be_a_bare_ip(self) -> None:
# Коробку часто так и оставляют: сервер по IP, без домена.
response = self.client.patch(
"/api/v1/company/administration/instance/",
"/api/v1/instance/settings/",
{"publicHost": "203.0.113.10", "publicScheme": "http"},
format="json",
)
@@ -393,7 +393,7 @@ class InstanceAddressTests(TestCase):
def test_url_is_accepted_and_trimmed_to_host(self) -> None:
response = self.client.patch(
"/api/v1/company/administration/instance/",
"/api/v1/instance/settings/",
{"publicHost": "https://crm.example.com/settings", "publicScheme": "https"},
format="json",
)
@@ -403,7 +403,7 @@ class InstanceAddressTests(TestCase):
def test_garbage_is_rejected(self) -> None:
response = self.client.patch(
"/api/v1/company/administration/instance/",
"/api/v1/instance/settings/",
{"publicHost": "не адрес!", "publicScheme": "ftp"},
format="json",
)
@@ -421,7 +421,7 @@ class InstanceEmailTests(TestCase):
def patch(self, **email):
return self.client.patch(
"/api/v1/company/administration/instance/",
"/api/v1/instance/settings/",
{"publicHost": "crm.example.com", "publicScheme": "https", "email": email},
format="json",
)
@@ -453,7 +453,7 @@ class InstanceEmailTests(TestCase):
def test_check_without_smtp_explains_itself(self) -> None:
response = self.client.post(
"/api/v1/company/administration/instance/email-check/", {}, format="json"
"/api/v1/instance/settings/email-check/", {}, format="json"
)
self.assertEqual(response.status_code, 400)
@@ -0,0 +1,215 @@
"""Приглашение существующей учётной записи во вторую организацию.
Учётная запись глобальная, членство — по согласию человека: создание
сотрудника с уже занятым e-mail выписывает приглашение, письмо уходит из
воркера с новым токеном, а членство появляется после принятия.
"""
from __future__ import annotations
from django.core import mail
from django.test import TestCase
from chatballs.events.handlers import dispatch
from chatballs.events.models import OutboxEvent
from chatballs.identity.group_models import EmployeeGroup
from chatballs.identity.invitation_models import OrganizationInvitation
from chatballs.identity.invitation_service import MEMBERSHIP_INVITATION_REQUESTED
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from chatballs.testing import TenantAPIClient
PASSWORD = "Long-and-strong-passphrase-42"
class InvitationTestBase(TestCase):
def setUp(self) -> None:
self.first = Organization.objects.create(name="First", slug="inv-first")
self.second = Organization.objects.create(name="Second", slug="inv-second")
self.second_owner = HumanUser.objects.create_user(
email="second-owner@example.test", password=PASSWORD, full_name="Second Owner"
)
self.second_membership = OrganizationMembership.objects.create(
organization=self.second,
user=self.second_owner,
role=EmployeeRole.OWNER,
position_title="Owner",
)
self.group = EmployeeGroup.objects.create(organization=self.second, name="Support")
# Человек уже работает в первой организации.
self.person = HumanUser.objects.create_user(
email="person@example.test", password=PASSWORD, full_name="Person"
)
OrganizationMembership.objects.create(
organization=self.first,
user=self.person,
role=EmployeeRole.EMPLOYEE,
position_title="Operator",
)
self.client = TenantAPIClient()
self.client.force_authenticate(self.second_owner)
self.client.set_tenant(self.second)
def _create(self, email: str = "person@example.test"):
return self.client.post(
"/api/v1/employees/operators/",
{
"email": email,
"fullName": "Person",
"positionTitle": "Support operator",
"role": EmployeeRole.EMPLOYEE,
"passwordMode": "mail",
"groupIds": [self.group.id],
},
format="json",
)
class InviteExistingUserTests(InvitationTestBase):
def test_existing_account_gets_an_invitation_instead_of_an_error(self) -> None:
response = self._create()
self.assertEqual(response.status_code, 201, response.content)
self.assertTrue(response.json()["invited"])
self.assertIsNone(response.json()["employee"])
self.assertFalse(
OrganizationMembership.objects.filter(user=self.person, organization=self.second).exists()
)
invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test")
self.assertEqual(invitation.role, EmployeeRole.EMPLOYEE)
self.assertEqual(invitation.position_title, "Support operator")
self.assertEqual(invitation.group_ids, [self.group.id])
self.assertTrue(
OutboxEvent.objects.filter(
event_type=MEMBERSHIP_INVITATION_REQUESTED, organization=self.second
).exists()
)
def test_same_organization_still_reports_the_address_as_taken(self) -> None:
response = self._create(email="second-owner@example.test")
self.assertEqual(response.status_code, 400)
self.assertFalse(OrganizationInvitation.objects.filter(organization=self.second).exists())
def test_worker_sends_the_letter_and_the_link_accepts(self) -> None:
self._create()
event = OutboxEvent.objects.get(event_type=MEMBERSHIP_INVITATION_REQUESTED)
dispatch(event)
self.assertEqual(len(mail.outbox), 1)
letter = mail.outbox[0]
self.assertEqual(letter.to, ["person@example.test"])
self.assertIn("/join?token=", letter.body)
token = letter.body.split("/join?token=", 1)[1].split()[0]
person_client = TenantAPIClient()
person_client.force_authenticate(self.person)
accepted = person_client.post("/api/v1/auth/invitations/accept/", {"token": token}, format="json")
self.assertEqual(accepted.status_code, 200, accepted.content)
self.assertEqual(accepted.json()["organizationPublicId"], str(self.second.public_id))
membership = OrganizationMembership.objects.get(user=self.person, organization=self.second)
self.assertEqual(membership.role, EmployeeRole.EMPLOYEE)
self.assertEqual(membership.position_title, "Support operator")
self.assertEqual([link.group_id for link in membership.group_links.all()], [self.group.id])
self.assertEqual(
{item["organizationPublicId"] for item in accepted.json()["user"]["memberships"]},
{str(self.first.public_id), str(self.second.public_id)},
)
def test_reinvite_replaces_the_pending_invitation(self) -> None:
self._create()
first = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test")
self._create()
first.refresh_from_db()
self.assertIsNotNone(first.revoked_at)
self.assertEqual(
OrganizationInvitation.objects.filter(
organization=self.second, email="person@example.test", revoked_at__isnull=True
).count(),
1,
)
def test_stranger_cannot_use_someone_elses_invitation(self) -> None:
self._create()
dispatch(OutboxEvent.objects.get(event_type=MEMBERSHIP_INVITATION_REQUESTED))
token = mail.outbox[0].body.split("/join?token=", 1)[1].split()[0]
stranger = HumanUser.objects.create_user(email="stranger@example.test", password=PASSWORD)
client = TenantAPIClient()
client.force_authenticate(stranger)
response = client.post("/api/v1/auth/invitations/accept/", {"token": token}, format="json")
self.assertEqual(response.status_code, 400)
self.assertFalse(
OrganizationMembership.objects.filter(user=stranger, organization=self.second).exists()
)
class PendingInvitationRowsTests(InvitationTestBase):
"""Ожидающие приглашения видны в списке сотрудников и управляются оттуда."""
def test_list_shows_the_invitation_with_role_position_and_groups(self) -> None:
self._create()
payload = self.client.get("/api/v1/employees/").json()
self.assertEqual(len(payload["invitations"]), 1)
row = payload["invitations"][0]
self.assertEqual(row["email"], "person@example.test")
self.assertEqual(row["fullName"], "Person")
self.assertEqual(row["role"], EmployeeRole.EMPLOYEE)
self.assertEqual(row["positionTitle"], "Support operator")
self.assertEqual([group["id"] for group in row["groups"]], [self.group.id])
# Фильтры списка действуют и на приглашения.
self.assertEqual(self.client.get("/api/v1/employees/?role=ADMIN").json()["invitations"], [])
self.assertEqual(len(self.client.get("/api/v1/employees/?q=person").json()["invitations"]), 1)
self.assertEqual(self.client.get("/api/v1/employees/?q=nobody").json()["invitations"], [])
def test_resend_extends_expiry_and_queues_a_new_letter(self) -> None:
self._create()
invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test")
OutboxEvent.objects.filter(event_type=MEMBERSHIP_INVITATION_REQUESTED).delete()
response = self.client.post(f"/api/v1/employees/invitations/{invitation.id}/resend/")
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(OutboxEvent.objects.filter(event_type=MEMBERSHIP_INVITATION_REQUESTED).count(), 1)
refreshed = OrganizationInvitation.objects.get(pk=invitation.id)
self.assertGreaterEqual(refreshed.expires_at, invitation.expires_at)
def test_revoke_hides_the_row_and_kills_the_link(self) -> None:
self._create()
dispatch(OutboxEvent.objects.get(event_type=MEMBERSHIP_INVITATION_REQUESTED))
token = mail.outbox[0].body.split("/join?token=", 1)[1].split()[0]
invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test")
response = self.client.post(f"/api/v1/employees/invitations/{invitation.id}/revoke/")
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(self.client.get("/api/v1/employees/").json()["invitations"], [])
person_client = TenantAPIClient()
person_client.force_authenticate(self.person)
accepted = person_client.post("/api/v1/auth/invitations/accept/", {"token": token}, format="json")
self.assertEqual(accepted.status_code, 400)
def test_employee_cannot_manage_invitations(self) -> None:
self._create()
invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test")
operator = HumanUser.objects.create_user(email="operator@example.test", password=PASSWORD)
OrganizationMembership.objects.create(
organization=self.second, user=operator, role=EmployeeRole.EMPLOYEE, position_title="Operator"
)
client = TenantAPIClient()
client.force_authenticate(operator)
client.set_tenant(self.second)
self.assertEqual(client.post(f"/api/v1/employees/invitations/{invitation.id}/revoke/").status_code, 403)
self.assertEqual(client.post(f"/api/v1/employees/invitations/{invitation.id}/resend/").status_code, 403)
@@ -21,6 +21,7 @@ from chatballs.identity.instance_settings import (
accepted_hosts,
invalidate_cache,
)
from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
from chatballs.support_portals.models import SupportPortal
from chatballs.testing import TenantAPIClient
@@ -42,7 +43,7 @@ class InstanceAddressChangeTests(TestCase):
def _patch(self, host: str, scheme: str = "https"):
return self.client.patch(
"/api/v1/company/administration/instance/",
"/api/v1/instance/settings/",
{"publicHost": host, "publicScheme": scheme},
format="json",
)
@@ -119,3 +120,84 @@ class PortalDomainCollisionTests(TestCase):
portal.clean() # не должно бросать
self.assertEqual(portal.custom_domain, "help.example.test")
class InstanceSettingsAccessTests(TestCase):
"""Настройки установки меняет только её администратор.
Владелец другой организации видит адреса TURN (карточка relay), но не может
переключить общий SMTP или адрес установки; сотрудник не видит ничего.
"""
def setUp(self) -> None:
self.result = bootstrap_owner(email="instance-owner@example.com", password=PASSWORD)
self.other_org = Organization.objects.create(name="Other", slug="other-org")
self.other_owner = HumanUser.objects.create_user(
email="other-owner@example.com", password=PASSWORD, full_name="Other Owner"
)
OrganizationMembership.objects.create(
organization=self.other_org,
user=self.other_owner,
role=EmployeeRole.OWNER,
position_title="Owner",
)
self.employee = HumanUser.objects.create_user(
email="employee@example.com", password=PASSWORD, full_name="Employee"
)
OrganizationMembership.objects.create(
organization=self.other_org,
user=self.employee,
role=EmployeeRole.EMPLOYEE,
position_title="Operator",
)
def _client(self, user: HumanUser) -> TenantAPIClient:
client = TenantAPIClient()
client.force_authenticate(user)
return client
def test_setup_owner_is_instance_admin_and_may_change_settings(self) -> None:
self.assertTrue(self.result.owner.is_instance_admin)
response = self._client(self.result.owner).patch(
"/api/v1/instance/settings/",
{"publicHost": "crm.example.test", "publicScheme": "https"},
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
def test_organization_owner_reads_but_cannot_change(self) -> None:
client = self._client(self.other_owner)
self.assertEqual(client.get("/api/v1/instance/settings/").status_code, 200)
self.assertEqual(client.get("/api/v1/instance/storage/").status_code, 200)
denied = client.patch(
"/api/v1/instance/settings/",
{"publicHost": "crm.example.test", "publicScheme": "https"},
format="json",
)
self.assertEqual(denied.status_code, 403)
self.assertEqual(
client.post("/api/v1/instance/settings/email-check/", {}, format="json").status_code, 403
)
self.assertEqual(
client.patch("/api/v1/instance/storage/", {"backend": "LOCAL"}, format="json").status_code,
403,
)
def test_employee_sees_nothing(self) -> None:
client = self._client(self.employee)
self.assertEqual(client.get("/api/v1/instance/settings/").status_code, 403)
self.assertEqual(client.get("/api/v1/instance/storage/").status_code, 403)
def test_session_reports_the_flag(self) -> None:
admin_session = self._client(self.result.owner).get("/api/v1/auth/session/").json()
owner_session = self._client(self.other_owner).get("/api/v1/auth/session/").json()
self.assertTrue(admin_session["user"]["isInstanceAdmin"])
self.assertFalse(owner_session["user"]["isInstanceAdmin"])
def test_old_organization_scoped_paths_are_gone(self) -> None:
client = self._client(self.result.owner)
client.organization_public_id = str(self.result.organization.public_id)
response = client.get(
f"/api/v1/organizations/{self.result.organization.public_id}/company/administration/instance/"
)
self.assertEqual(response.status_code, 404)
@@ -0,0 +1,135 @@
"""Приглашение владельца из платформенного провижининга доходит до человека.
Учётной записи может ещё не быть: письмо уходит из воркера, а по ссылке
человек задаёт имя и пароль, принимает приглашение и оказывается владельцем
активированной организации. Существующая учётная запись идёт на обычный вход.
"""
from __future__ import annotations
from django.core import mail
from django.test import TestCase, override_settings
from chatballs.events.handlers import dispatch
from chatballs.events.models import OutboxEvent
from chatballs.identity.invitation_service import OWNER_INVITATION_REQUESTED
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
OrganizationMembership,
OrganizationStatus,
)
from chatballs.platform.provisioning_command import ProvisioningCommand
from chatballs.platform.provisioning_service import provision_organization
from chatballs.platform.testing import create_platform_operator
from chatballs.testing import TenantAPIClient
PASSWORD = "Very-strong-passphrase-42"
@override_settings(ROOT_URLCONF="chatballs_backend.urls_app")
class OwnerInvitationFlowTests(TestCase):
def setUp(self) -> None:
self.operator, _ = create_platform_operator()
def _provision(self, email: str = "new-owner@example.test"):
return provision_organization(
command=ProvisioningCommand(
organization_name="Fresh Co",
organization_slug="fresh-co",
owner_email=email,
source="PLATFORM_OPERATOR",
idempotency_key=f"idem-{email}",
),
operator=self.operator,
)
def _token_from_mail(self) -> str:
return mail.outbox[-1].body.split("/join?token=", 1)[1].split()[0]
def test_letter_goes_out_and_a_new_person_registers_as_owner(self) -> None:
result = self._provision()
dispatch(OutboxEvent.objects.get(event_type=OWNER_INVITATION_REQUESTED))
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["new-owner@example.test"])
token = self._token_from_mail()
client = TenantAPIClient()
preview = client.get(f"/api/v1/auth/invitations/preview/?token={token}").json()
self.assertEqual(preview["valid"], True)
self.assertEqual(preview["organizationName"], "Fresh Co")
self.assertFalse(preview["accountExists"])
response = client.post(
"/api/v1/auth/invitations/register/",
{"token": token, "fullName": "New Owner", "password": PASSWORD},
format="json",
)
self.assertEqual(response.status_code, 201, response.content)
body = response.json()
self.assertEqual(body["organizationPublicId"], str(result.organization.public_id))
self.assertEqual(body["user"]["email"], "new-owner@example.test")
self.assertFalse(body["user"]["isInstanceAdmin"])
result.organization.refresh_from_db()
self.assertEqual(result.organization.status, OrganizationStatus.ACTIVE)
owner = HumanUser.objects.get(email="new-owner@example.test")
self.assertTrue(owner.check_password(PASSWORD))
self.assertFalse(owner.must_change_password)
self.assertTrue(
OrganizationMembership.objects.filter(
organization=result.organization, user=owner, role=EmployeeRole.OWNER
).exists()
)
# Сессия установлена: приложение сразу открывается под владельцем.
self.assertTrue(client.get("/api/v1/auth/session/").json()["authenticated"])
def test_weak_password_and_empty_name_are_reported_by_field(self) -> None:
self._provision()
dispatch(OutboxEvent.objects.get(event_type=OWNER_INVITATION_REQUESTED))
token = self._token_from_mail()
client = TenantAPIClient()
weak = client.post(
"/api/v1/auth/invitations/register/",
{"token": token, "fullName": "New Owner", "password": "short"},
format="json",
)
nameless = client.post(
"/api/v1/auth/invitations/register/",
{"token": token, "fullName": " ", "password": PASSWORD},
format="json",
)
self.assertEqual(weak.status_code, 400)
self.assertIn("password", weak.json()["errors"])
self.assertEqual(nameless.status_code, 400)
self.assertIn("fullName", nameless.json()["errors"])
self.assertFalse(HumanUser.objects.filter(email="new-owner@example.test").exists())
def test_existing_account_is_sent_to_login_and_cannot_register(self) -> None:
HumanUser.objects.create_user(email="known@example.test", password=PASSWORD, is_active=True)
# Активная учётная запись получает владение сразу, приглашение не нужно:
# проверяем ветку через приглашение сотрудника той же организации.
result = self._provision(email="known-owner@example.test")
dispatch(OutboxEvent.objects.get(event_type=OWNER_INVITATION_REQUESTED))
token = self._token_from_mail()
HumanUser.objects.create_user(email="known-owner@example.test", password=PASSWORD)
client = TenantAPIClient()
preview = client.get(f"/api/v1/auth/invitations/preview/?token={token}").json()
register = client.post(
"/api/v1/auth/invitations/register/",
{"token": token, "fullName": "Someone", "password": PASSWORD},
format="json",
)
self.assertTrue(preview["accountExists"])
self.assertEqual(register.status_code, 400)
self.assertEqual(register.json()["code"], "account_exists")
self.assertEqual(result.organization.status, OrganizationStatus.PENDING_OWNER)
def test_unknown_token_previews_as_invalid(self) -> None:
response = TenantAPIClient().get("/api/v1/auth/invitations/preview/?token=nope")
self.assertEqual(response.json(), {"valid": False})
@@ -101,6 +101,9 @@ class SetupWizardTests(TestCase):
owner = HumanUser.objects.get(email="e.kuznetsova@atelie-nord.ru")
self.assertTrue(owner.check_password(VALID["password"]))
self.assertTrue(owner.is_superuser)
# Владелец из мастера — администратор установки: раздел «Платформа» его.
self.assertTrue(owner.is_instance_admin)
self.assertTrue(user["isInstanceAdmin"])
membership = OrganizationMembership.objects.get(user=owner, organization=organization)
self.assertEqual(membership.role, EmployeeRole.OWNER)
self.assertTrue(KnowledgeCategory.objects.filter(organization=organization).exists())
@@ -18,12 +18,12 @@ OpenAI-совместимый сервер или собственный Bot API
from __future__ import annotations
from chatballs.i18n import t
import ipaddress
import socket
from urllib.parse import urlsplit
from chatballs.i18n import t
HTTP_SCHEMES = frozenset({"http", "https"})
PROXY_SCHEMES = frozenset({"http", "https", "socks5", "socks5h"})
+1 -1
View File
@@ -5,9 +5,9 @@ from unittest import mock
from django.core.exceptions import ValidationError
from django.test import TestCase
from chatballs.i18n import tn
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.i18n import tn
from chatballs.integrations import checks
from chatballs.integrations.models import (
Integration,
@@ -16,8 +16,8 @@ from django.utils import timezone
from chatballs.conversations import transports
from chatballs.conversations.transports.base import InboundMessage
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.i18n import customer_language, first_chosen, normalize_language, t
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.notifications.models import MessengerBinding, MessengerBindingCode
logger = logging.getLogger(__name__)
@@ -8,6 +8,8 @@ from django.utils import timezone
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.audit import record_audit_event
from chatballs.identity.invitation_service import issue_invitation
from chatballs.identity.models import (
@@ -173,7 +175,7 @@ def _provision_active_owner(
user=owner_user,
organization=org,
role=EmployeeRole.OWNER,
position_title=_owner_position_title(),
position_title=_owner_position_title(org),
)
record_audit_event(
action="organization.provisioned",
@@ -228,8 +230,10 @@ def _provision_pending_owner(
)
def _owner_position_title() -> str:
return "Владелец"
def _owner_position_title(org: Organization) -> str:
# Должность хранится текстом, поэтому пишется сразу на языке организации,
# а не оператора платформы: переводить её потом будет нечем.
return t("setup.owner_position", language=customer_language(org))
def _safe_message(error: Exception) -> str:
@@ -0,0 +1,100 @@
from __future__ import annotations
from django.db import connection
from django.test import TransactionTestCase, override_settings
from rest_framework.test import APIClient
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
OrganizationInvitation,
OrganizationMembership,
)
from chatballs.platform.models import OrganizationProvisioning, ProvisioningStatus
from chatballs.platform.testing import create_platform_operator
@override_settings(ROOT_URLCONF="chatballs_backend.urls_platform")
class PlatformRoleProvisioningTests(TransactionTestCase):
"""Провижининг под реальной runtime-ролью platform (tenancy/0031).
Остальные тесты ходят в базу владельцем кластера и не заметили бы
отсутствующий GRANT: в деплое backend-platform работает ролью
chatballs_platform, и без прав на платформенные таблицы уже проверка
токена падала с «permission denied». Здесь весь HTTP-запрос выполняется
под этой ролью — от чтения токена до записи приглашения владельца.
"""
def setUp(self) -> None:
self.operator, self.token = create_platform_operator()
def _post(self, token: str, *, slug: str, owner_email: str, key: str):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Token {token}")
with connection.cursor() as cursor:
cursor.execute("SET ROLE chatballs_runtime_platform")
try:
return client.post(
"/api/v1/organizations",
data={
"name": "Role Co",
"slug": slug,
"owner_email": owner_email,
"timezone": "Europe/Moscow",
"currency": "RUB",
},
format="json",
HTTP_IDEMPOTENCY_KEY=key,
)
finally:
with connection.cursor() as cursor:
cursor.execute("RESET ROLE")
def test_unknown_token_is_rejected_not_crashed(self) -> None:
response = self._post(
"ctp_not_a_real_token", slug="role-co", owner_email="x@example.test", key="k0"
)
self.assertEqual(response.status_code, 401, response.content)
def test_active_owner_is_provisioned_under_platform_role(self) -> None:
HumanUser.objects.create_user(email="role-owner@example.test")
response = self._post(
self.token, slug="role-co", owner_email="role-owner@example.test", key="k1"
)
self.assertEqual(response.status_code, 201, response.content)
payload = response.json()
self.assertEqual(payload["owner"]["state"], "active")
self.assertEqual(payload["provisioning"]["status"], ProvisioningStatus.COMPLETED)
self.assertTrue(
OrganizationMembership.objects.filter(
organization__slug="role-co", role=EmployeeRole.OWNER
).exists()
)
record = OrganizationProvisioning.objects.get(idempotency_key="k1")
self.assertEqual(record.status, ProvisioningStatus.COMPLETED)
def test_pending_owner_gets_invitation_under_platform_role(self) -> None:
response = self._post(
self.token, slug="role-co", owner_email="new-owner@example.test", key="k2"
)
self.assertEqual(response.status_code, 201, response.content)
self.assertEqual(response.json()["owner"]["state"], "pending_invitation")
self.assertTrue(
OrganizationInvitation.objects.filter(
organization__slug="role-co",
email="new-owner@example.test",
role=EmployeeRole.OWNER,
).exists()
)
def test_replay_is_idempotent_under_platform_role(self) -> None:
HumanUser.objects.create_user(email="role-owner@example.test")
first = self._post(
self.token, slug="role-co", owner_email="role-owner@example.test", key="k3"
)
second = self._post(
self.token, slug="role-co", owner_email="role-owner@example.test", key="k3"
)
self.assertEqual(first.status_code, 201, first.content)
self.assertEqual(second.status_code, 200, second.content)
self.assertEqual(OrganizationProvisioning.objects.filter(idempotency_key="k3").count(), 1)
+7 -3
View File
@@ -13,6 +13,7 @@ from chatballs.platform.payloads import owner_state_for, provisioning_result_pay
from chatballs.platform.permissions import HasPlatformCapability
from chatballs.platform.provisioning_service import provision_organization
from chatballs.platform.validation import parse_provisioning_body
from chatballs.tenancy.database import tenant_atomic
_IDEMPOTENCY_HEADER = "Idempotency-Key"
@@ -44,9 +45,12 @@ class OrganizationProvisionView(APIView):
result = provision_organization(command=command, operator=request.user)
except ProvisioningError as error:
return Response({"detail": str(error)}, status=error.status_code)
owner_membership = OrganizationMembership.objects.filter(
organization=result.organization, role=EmployeeRole.OWNER
).first()
# Членство — тенантная строка: под ролью platform без tenant-контекста
# RLS её не покажет, и ответ назвал бы активного владельца ожидающим.
with tenant_atomic(result.organization.pk):
owner_membership = OrganizationMembership.objects.filter(
organization=result.organization, role=EmployeeRole.OWNER
).first()
payload = provisioning_result_payload(
provisioning=result.provisioning,
organization=result.organization,
@@ -3,12 +3,12 @@ from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.views import APIView
from chatballs.identity.models import Organization
from chatballs.support_portals.content_services import INLINE_CONTENT_TYPES
from chatballs.support_portals.models import PortalArticleFile
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import portal_article_file_route
from chatballs.tenancy.lookup import load_organization
class PortalArticleFileView(APIView):
@@ -26,10 +26,9 @@ class PortalArticleFileView(APIView):
route = portal_article_file_route(str(public_id))
if route is None:
raise Http404
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist as error:
raise Http404 from error
organization = load_organization(route.organization_id)
if organization is None:
raise Http404
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
article_file = PortalArticleFile.objects.filter(
@@ -5,7 +5,6 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from chatballs.i18n import t
from chatballs.identity.models import Organization
from chatballs.support_portals.content_services import record_feedback
from chatballs.support_portals.models import SupportPortal
from chatballs.support_portals.selectors import category_article_counts, public_articles
@@ -17,6 +16,7 @@ from chatballs.support_portals.serializers import (
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import support_portal_route
from chatballs.tenancy.lookup import load_organization
class PublicPortalView(APIView):
@@ -28,9 +28,8 @@ class PublicPortalView(APIView):
route = support_portal_route(hostname)
if route is None:
return None
try:
organization = Organization.objects.get(id=route.organization_id)
except Organization.DoesNotExist:
organization = load_organization(route.organization_id)
if organization is None:
return None
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
+45 -3
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from django.conf import settings
from django.db import connections
@@ -14,8 +13,9 @@ class IngressRoute:
def _rows(query: str, parameters: list[Any]) -> list[tuple]:
alias = "default" if settings.TESTING else "platform"
with connections[alias].cursor() as cursor:
# Каталоги — security-barrier вьюхи, на них есть SELECT у роли app
# (tenancy/0032): чтение идёт по основному соединению процесса.
with connections["default"].cursor() as cursor:
cursor.execute(query, parameters)
return list(cursor.fetchall())
@@ -84,3 +84,45 @@ def support_portal_route(hostname: str) -> IngressRoute | None:
return _unique_route("support_portal_directory", hostname.strip().lower().rstrip("."))
# Каталог организаций: id по публичному id или слагу, публичный id по id и
# список всех id. Роль app видит строку организации только в её контексте
# (tenancy/0033), а сюда приходят до того, как контекст открыт.
def organization_route_by_public_id(public_id: str) -> IngressRoute | None:
rows = _rows(
"SELECT organization_id, public_id FROM chatballs.organization_directory "
"WHERE public_id = %s::uuid",
[public_id],
)
if len(rows) != 1:
return None
return IngressRoute(organization_id=int(rows[0][0]), resource_id=str(rows[0][1]))
def organization_route_by_slug(slug: str) -> IngressRoute | None:
rows = _rows(
"SELECT organization_id, slug FROM chatballs.organization_directory WHERE slug = %s",
[slug],
)
if len(rows) != 1:
return None
return IngressRoute(organization_id=int(rows[0][0]), resource_id=str(rows[0][1]))
def organization_public_id_of(organization_id: int) -> str | None:
rows = _rows(
"SELECT public_id FROM chatballs.organization_directory WHERE organization_id = %s",
[int(organization_id)],
)
return str(rows[0][0]) if rows else None
def organization_ids() -> list[int]:
return [
int(row[0])
for row in _rows(
"SELECT organization_id FROM chatballs.organization_directory ORDER BY organization_id",
[],
)
]
+74
View File
@@ -0,0 +1,74 @@
"""Поиск организации там, где tenant-контекста ещё нет.
Роль app видит строку организации только в контексте этой организации
(tenancy/0033). Входы, которые начинаются с внешнего ключа — адрес, публичный
id, слаг, id из outbox-события, — сначала находят id через security-barrier
каталог ``chatballs.organization_directory`` и лишь затем открывают контекст и
читают строку. Так процесс приложения не перечисляет чужие организации.
"""
from __future__ import annotations
from collections.abc import Iterator
from django.db import DEFAULT_DB_ALIAS, connections
from chatballs.identity.models import Organization
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import (
organization_ids,
organization_route_by_public_id,
organization_route_by_slug,
)
def load_organization(organization_id: int) -> Organization | None:
"""Строка организации по id: читается в её собственном контексте."""
with tenant_atomic(int(organization_id)):
return Organization.objects.filter(pk=organization_id).first()
def organization_by_public_id(public_id: object) -> Organization | None:
route = organization_route_by_public_id(str(public_id))
return load_organization(route.organization_id) if route is not None else None
def organization_by_slug(slug: str) -> Organization | None:
route = organization_route_by_slug(slug)
return load_organization(route.organization_id) if route is not None else None
def iter_organizations() -> Iterator[Organization]:
"""Все организации установки по одной, каждая в своём контексте (воркер)."""
for organization_id in organization_ids():
organization = load_organization(organization_id)
if organization is not None:
yield organization
def reserve_organization_id(*, using: str = DEFAULT_DB_ALIAS) -> int:
"""Выделить id для будущей организации до INSERT.
Роль app видит строку организации только в её контексте, а INSERT с
RETURNING обязан вернуть видимую строку. Поэтому мастер первого запуска
берёт id из последовательности заранее, открывает контекст этого id и уже
в нём вставляет строку.
"""
with connections[using].cursor() as cursor:
cursor.execute("SELECT nextval('identity_organization_id_seq')")
return int(cursor.fetchone()[0])
def instance_has_organizations(*, using: str = DEFAULT_DB_ALIAS) -> bool:
"""Есть ли в установке хоть одна организация — без tenant-контекста.
Проверку делает SECURITY DEFINER-функция (tenancy/0032): та же, что держит
политику «первая организация» мастера первого запуска.
"""
with connections[using].cursor() as cursor:
cursor.execute("SELECT chatballs.instance_has_organizations()")
return bool(cursor.fetchone()[0])
@@ -1,15 +1,16 @@
from __future__ import annotations
import json
import uuid
from datetime import UTC, datetime
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from chatballs.ai.models import KnowledgeAttachment
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.lookup import organization_by_public_id
from chatballs.tenancy.media_migration import (
copy_attachment,
reconcile_attachment_storage_usage,
@@ -34,11 +35,11 @@ class Command(BaseCommand):
def handle(self, *args, **options) -> None:
try:
organization = Organization.objects.get(
public_id=options["organization_public_id"]
)
except (Organization.DoesNotExist, ValueError) as error:
organization = organization_by_public_id(uuid.UUID(str(options["organization_public_id"])))
except ValueError as error:
raise CommandError("Organization not found") from error
if organization is None:
raise CommandError("Organization not found")
source_root = Path(options["source_root"])
manifest_path = Path(options["manifest"])
+8 -3
View File
@@ -8,9 +8,10 @@ from django.http import Http404, HttpRequest, HttpResponse
from django.urls import Resolver404, resolve
from chatballs.events.context import get_correlation_id
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.identity.models import OrganizationMembership
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.lookup import organization_by_public_id
class TenantContextMiddleware:
@@ -47,9 +48,13 @@ class TenantContextMiddleware:
raise Http404
try:
public_id = uuid.UUID(match.group("public_id"))
organization = Organization.objects.get(public_id=public_id)
except (ValueError, Organization.DoesNotExist) as error:
except ValueError as error:
raise Http404 from error
# Строка организации видна роли app только в её контексте: id берётся
# из каталога, а сама строка читается уже внутри tenant_atomic.
organization = organization_by_public_id(public_id)
if organization is None:
raise Http404
with tenant_atomic(organization.pk):
try:
@@ -1,6 +1,9 @@
from django.db import migrations
# Исторический список на момент миграции: identity_department и три таблицы
# access-профилей позже удалены (identity/0020), ai_knowledgedepartment — в
# ai/0014. Актуальное покрытие RLS — сумма всех миграций tenancy, не эта константа.
TENANT_TABLES = (
"identity_department",
"identity_employeeprofile",
@@ -0,0 +1,53 @@
# Таблицы платформенного приложения (operator, token, provisioning) появились
# после tenancy/0003, и ни одна миграция не выдала на них прав runtime-роли
# platform. В деплое backend-platform работает ролью chatballs_platform, поэтому
# уже проверка токена падала «permission denied for table platform_platformtoken»,
# а вместе с ней — весь POST /api/v1/organizations (SPEC-HUB-0021 §12). Тесты
# этого не видели: они ходят в базу владельцем кластера.
#
# Здесь роль platform получает DML на платформенные таблицы (без DELETE: токены
# отзываются, записи провижининга хранятся) и на приглашения — их выписывает тот
# же провижининг для будущего владельца внутри set_local_tenant(new_org_id), под
# той же tenant-политикой, что в 0005 для членств.
from django.db import migrations
PLATFORM_POLICY = "chatballs_platform_tenant_provisioning"
GRANTS = f"""
GRANT SELECT, INSERT, UPDATE ON platform_platformoperator, platform_platformtoken,
platform_organizationprovisioning TO chatballs_runtime_platform;
GRANT USAGE, SELECT ON SEQUENCE platform_platformoperator_id_seq,
platform_platformtoken_id_seq, platform_organizationprovisioning_id_seq
TO chatballs_runtime_platform;
GRANT SELECT, INSERT, UPDATE ON identity_organizationinvitation
TO chatballs_runtime_platform;
GRANT USAGE, SELECT ON SEQUENCE identity_organizationinvitation_id_seq
TO chatballs_runtime_platform;
DROP POLICY IF EXISTS {PLATFORM_POLICY} ON identity_organizationinvitation;
CREATE POLICY {PLATFORM_POLICY} ON identity_organizationinvitation
FOR ALL TO chatballs_runtime_platform
USING (organization_id = chatballs.current_organization_id())
WITH CHECK (organization_id = chatballs.current_organization_id());
"""
REVOKE = f"""
DROP POLICY IF EXISTS {PLATFORM_POLICY} ON identity_organizationinvitation;
REVOKE ALL ON identity_organizationinvitation FROM chatballs_runtime_platform;
REVOKE ALL ON SEQUENCE identity_organizationinvitation_id_seq
FROM chatballs_runtime_platform;
REVOKE ALL ON platform_platformoperator, platform_platformtoken,
platform_organizationprovisioning FROM chatballs_runtime_platform;
REVOKE ALL ON SEQUENCE platform_platformoperator_id_seq,
platform_platformtoken_id_seq, platform_organizationprovisioning_id_seq
FROM chatballs_runtime_platform;
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0030_encrypted_column_widths"),
("platform", "0001_initial"),
]
operations = [migrations.RunSQL(GRANTS, REVOKE)]
@@ -0,0 +1,72 @@
# Процесс backend-app держал platform-соединение ради двух вещей: чтения
# каталогов входа (виджет, портал, приглашение в звонок) и мастера первого
# запуска. Из-за этого пароль роли platform лежал в каждом процессе стека.
#
# Каталоги — security-barrier вьюхи над тенантными таблицами: они и задуманы как
# безопасный вход без tenant-контекста, роль app получает на них SELECT.
#
# Мастер первого запуска создаёт первую организацию. Право INSERT на
# identity_organization у роли app появляется под политикой «пока организаций
# нет»: проверку делает SECURITY DEFINER-функция от chatballs_schema (иначе
# политика ссылалась бы на свою же таблицу и Postgres отказал бы за рекурсию).
# После первой организации INSERT для app закрыт навсегда — создавать следующие
# по-прежнему может только роль platform (SPEC-HUB-0021 §10).
from django.db import migrations
INGRESS_VIEWS = (
"attachment_directory",
"call_invite_directory",
"call_session_directory",
"membership_directory",
"organization_directory",
"portal_article_file_directory",
"support_portal_directory",
"web_channel_directory",
"web_session_directory",
"web_widget_directory",
)
BOOTSTRAP_POLICY = "chatballs_organization_app_bootstrap"
BOOTSTRAP_SQL = f"""
CREATE OR REPLACE FUNCTION chatballs.instance_has_organizations() RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$
SELECT EXISTS (SELECT 1 FROM identity_organization)
$$;
ALTER FUNCTION chatballs.instance_has_organizations() OWNER TO chatballs_schema;
REVOKE ALL ON FUNCTION chatballs.instance_has_organizations() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION chatballs.instance_has_organizations()
TO chatballs_runtime_app, chatballs_schema;
GRANT INSERT ON identity_organization TO chatballs_runtime_app;
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());
"""
BOOTSTRAP_REVERSE_SQL = f"""
DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization;
REVOKE INSERT ON identity_organization FROM chatballs_runtime_app;
DROP FUNCTION IF EXISTS chatballs.instance_has_organizations();
"""
def grant_ingress(apps, schema_editor):
for view in INGRESS_VIEWS:
schema_editor.execute(f"GRANT SELECT ON chatballs.{view} TO chatballs_runtime_app")
schema_editor.execute(BOOTSTRAP_SQL)
def revoke_ingress(apps, schema_editor):
schema_editor.execute(BOOTSTRAP_REVERSE_SQL)
for view in INGRESS_VIEWS:
schema_editor.execute(f"REVOKE SELECT ON chatballs.{view} FROM chatballs_runtime_app")
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0031_platform_provisioning_table_grants"),
]
operations = [migrations.RunPython(grant_ingress, revoke_ingress)]
@@ -0,0 +1,30 @@
# Роль app читала таблицу организаций целиком (0012: SELECT USING (true)) —
# любой процесс приложения без tenant-контекста мог перечислить чужие
# организации с именами, слагами и часовыми поясами. Теперь строка видна
# только в контексте своей организации. Входы без контекста (адрес, публичный
# id, слаг, id из outbox) идут через security-barrier каталог
# chatballs.organization_directory (tenancy/lookup), а мастер первого запуска
# спрашивает о наличии организаций SECURITY DEFINER-функцию (0032).
from django.db import migrations
SCOPED = """
DROP POLICY IF EXISTS chatballs_organization_app_select ON identity_organization;
CREATE POLICY chatballs_organization_app_select ON identity_organization
FOR SELECT TO chatballs_runtime_app
USING (id = chatballs.current_organization_id());
"""
UNSCOPED = """
DROP POLICY IF EXISTS chatballs_organization_app_select ON identity_organization;
CREATE POLICY chatballs_organization_app_select ON identity_organization
FOR SELECT TO chatballs_runtime_app
USING (true);
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0032_app_role_ingress_and_bootstrap"),
]
operations = [migrations.RunSQL(SCOPED, UNSCOPED)]
+7 -5
View File
@@ -1,10 +1,12 @@
"""Принудительный выбор соединения БД для блока кода.
Runtime backend-app держит два соединения: ``default`` (роль app) и
``platform`` (роль platform, права на создание организаций — SPEC-HUB-0021).
Обычные запросы идут в ``default``; операции уровня инстанса (мастер первого
запуска) выполняются целиком на ``platform`` через ``use_database("platform")``,
чтобы транзакция и RLS-контекст жили на одном соединении.
Алиас ``platform`` (роль platform, права на создание организаций —
SPEC-HUB-0021) есть только у процессов, которым он нужен: у платформенной
поверхности и у воркера, захватывающего outbox всех организаций
(см. ``settings_database.build_databases``). У backend-app его нет: каталоги
входа и мастер первого запуска работают ролью app (tenancy/0032).
``use_database`` переводит блок кода целиком на выбранный алиас, чтобы
транзакция и RLS-контекст жили на одном соединении.
"""
from __future__ import annotations
+14 -15
View File
@@ -1,12 +1,14 @@
"""Настройки хранилища файлов в «Настройках» администратора.
GET company/administration/storage/ — текущее состояние (секреты замаскированы)
PATCH company/administration/storage/ — сохранить; переключение на S3 проверяет доступ
POST company/administration/storage/check/ — проверить реквизиты (без сохранения)
POST company/administration/storage/migrate/ — перенести локальные файлы в S3 (worker)
GET instance/storage/ — текущее состояние (секреты замаскированы)
PATCH instance/storage/ — сохранить; переключение на S3 проверяет доступ
POST instance/storage/check/ — проверить реквизиты (без сохранения)
POST instance/storage/migrate/ — перенести локальные файлы в S3 (worker)
Настройка общая для инстанса: файлы всех организаций лежат в одном месте под
своими префиксами organizations/<public_id>/.
своими префиксами organizations/<public_id>/. Поэтому путь без организации,
а менять её вправе только администратор установки (identity.instance_access);
записи аудита и событие переноса — уровня установки, без организации.
"""
from __future__ import annotations
@@ -16,10 +18,10 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.instance_access import InstanceSettingsPermission, IsInstanceAdmin
from chatballs.tenancy import storage_settings as ss
STORAGE_MIGRATION_REQUESTED = "storage.migration_requested"
@@ -81,8 +83,7 @@ def _apply_fields(row: ss.StorageSettings, body: dict) -> ss.StorageConfig:
class StorageSettingsView(APIView):
permission_classes = [HasCapability]
required_capabilities = {"GET": "settings.view", "PATCH": "company.manage"}
permission_classes = [InstanceSettingsPermission]
def get(self, request: Request) -> Response:
return Response({"storage": storage_payload(ss.StorageSettings.load())})
@@ -113,7 +114,7 @@ class StorageSettingsView(APIView):
record_audit_event(
action="administration.storage_updated",
actor=request.user,
organization=request.tenant_context.organization,
organization=None,
object_type="StorageSettings",
object_id=str(row.pk),
payload={"backend": backend, "bucket": row.s3_bucket if backend == ss.StorageBackend.S3 else ""},
@@ -125,8 +126,7 @@ class StorageSettingsView(APIView):
class StorageCheckView(APIView):
"""Проверка реквизитов S3 без сохранения: пробная запись и удаление объекта."""
permission_classes = [HasCapability]
required_capability = "company.manage"
permission_classes = [IsInstanceAdmin]
def post(self, request: Request) -> Response:
row = ss.StorageSettings.load()
@@ -145,8 +145,7 @@ class StorageCheckView(APIView):
class StorageMigrateView(APIView):
"""Перенос локальных файлов в S3 — фоновой задачей worker'а."""
permission_classes = [HasCapability]
required_capability = "company.manage"
permission_classes = [IsInstanceAdmin]
def post(self, request: Request) -> Response:
row = ss.StorageSettings.load()
@@ -169,13 +168,13 @@ class StorageMigrateView(APIView):
aggregate_id=str(row.pk),
event_type=STORAGE_MIGRATION_REQUESTED,
payload={"requestedBy": request.user.id},
tenant_context=request.tenant_context,
tenant_context=None,
)
)
record_audit_event(
action="administration.storage_migration_requested",
actor=request.user,
organization=request.tenant_context.organization,
organization=None,
object_type="StorageSettings",
object_id=str(row.pk),
request=request,
@@ -0,0 +1,53 @@
from __future__ import annotations
from django.db import DatabaseError, connection, transaction
from django.test import TransactionTestCase
from chatballs.identity.models import EmployeeRole, Organization, OrganizationMembership
from chatballs.identity.setup import SetupInput, complete_setup
from chatballs.tenancy.ingress import organization_ids
from chatballs.tenancy.lookup import instance_has_organizations
class AppRoleBootstrapPolicyTests(TransactionTestCase):
"""Мастер первого запуска работает ролью app (tenancy/0032).
Пока организаций нет, роль app вправе создать первую; сразу после этого
INSERT для неё закрыт, и следующие организации создаёт только роль
platform. Проверяется под реальной runtime-ролью, а не владельцем кластера.
"""
def _set_role(self, role: str) -> None:
with connection.cursor() as cursor:
cursor.execute(f"SET ROLE {role}")
def _reset_role(self) -> None:
with connection.cursor() as cursor:
cursor.execute("RESET ROLE")
def test_setup_creates_first_organization_under_app_role_only_once(self) -> None:
self._set_role("chatballs_runtime_app")
try:
result = complete_setup(
SetupInput(
organization_name="Bootstrap Co",
full_name="First Owner",
email="first-owner@example.test",
password="Long-and-strong-passphrase-42",
)
)
# Без контекста строки организаций роли app не видны (tenancy/0033):
# факт создания проверяется через каталог.
self.assertTrue(instance_has_organizations())
self.assertEqual(len(organization_ids()), 1)
with transaction.atomic(), self.assertRaises(DatabaseError):
Organization.objects.create(name="Second", slug="second-co")
finally:
self._reset_role()
self.assertTrue(
OrganizationMembership.objects.filter(
organization=result.organization,
user=result.owner,
role=EmployeeRole.OWNER,
).exists()
)
+62 -1
View File
@@ -1,3 +1,4 @@
from django.core.exceptions import ValidationError
from django.db import DatabaseError, connection, transaction
from django.test import TransactionTestCase
@@ -162,7 +163,9 @@ class RowLevelSecurityTests(TransactionTestCase):
with self.assertRaises(DatabaseError), transaction.atomic():
# Чужая организация в контексте первой не видна вовсе (tenancy/0033):
# проверка внешнего ключа в full_clean отказывает ещё до INSERT.
with self.assertRaises((DatabaseError, ValidationError)), transaction.atomic():
self._set_role("chatballs_runtime_app")
@@ -426,6 +429,23 @@ class RowLevelSecurityTests(TransactionTestCase):
def test_app_role_reads_ingress_directory_and_cannot_add_organizations(self) -> None:
# Каталоги входа доступны роли app (tenancy/0032): backend-app
# обходится без platform-соединения.
with transaction.atomic():
self._set_role("chatballs_runtime_app")
with connection.cursor() as cursor:
cursor.execute(
"SELECT organization_id FROM chatballs.membership_directory "
"WHERE user_id = %s",
[self.user.id],
)
self.assertEqual(cursor.fetchone()[0], self.first.id)
# Организации уже есть — INSERT для app закрыт политикой bootstrap.
with self.assertRaises(DatabaseError), transaction.atomic():
self._set_role("chatballs_runtime_app")
Organization.objects.create(name="Third", slug="rls-third")
def test_platform_role_can_only_use_ingress_directory(self) -> None:
with self.assertRaises(DatabaseError), transaction.atomic():
@@ -464,6 +484,47 @@ class RowLevelSecurityTests(TransactionTestCase):
def test_app_role_sees_organizations_only_in_their_context(self) -> None:
# tenancy/0033: без контекста строк организаций нет, в контексте — своя.
with transaction.atomic():
self._set_role("chatballs_runtime_app")
self.assertEqual(Organization.objects.count(), 0)
set_local_tenant(self.first.id)
self.assertEqual(
list(Organization.objects.values_list("id", flat=True)), [self.first.id]
)
def test_app_role_finds_organizations_through_the_directory(self) -> None:
from chatballs.tenancy.lookup import (
instance_has_organizations,
iter_organizations,
organization_by_public_id,
)
with connection.cursor() as cursor:
cursor.execute("SET ROLE chatballs_runtime_app")
try:
self.assertTrue(instance_has_organizations())
self.assertEqual(
[organization.id for organization in iter_organizations()],
[self.first.id, self.second.id],
)
found = organization_by_public_id(self.second.public_id)
self.assertIsNotNone(found)
self.assertEqual(found.id, self.second.id)
# Сессия собирает членства по каталогу и читает каждую организацию
# в её контексте.
client = TenantAPIClient()
client.force_authenticate(self.user)
session = client.get("/api/v1/auth/session/").json()
self.assertEqual(
[item["organizationPublicId"] for item in session["user"]["memberships"]],
[str(self.first.public_id)],
)
finally:
with connection.cursor() as cursor:
cursor.execute("RESET ROLE")
def test_transaction_local_context_clears_after_commit_and_rollback(self) -> None:
with transaction.atomic():
@@ -43,7 +43,9 @@ class StorageSettingsTests(TestCase):
DynamicTenantStorage._s3_cache = None
self.organization = Organization.objects.create(name="Acme", slug="acme")
self.owner = HumanUser.objects.create_user(email="owner@example.com", password="Password-123", full_name="Owner")
self.owner = HumanUser.objects.create_user(
email="owner@example.com", password="Password-123", full_name="Owner", is_instance_admin=True
)
OrganizationMembership.objects.create(organization=self.organization, user=self.owner, role=EmployeeRole.OWNER, position_title="Owner")
self.client = TenantAPIClient()
self.client.force_authenticate(self.owner)
@@ -133,7 +135,7 @@ class StorageSettingsTests(TestCase):
stored = cursor.fetchone()[0]
self.assertNotEqual(stored, "very-secret")
self.assertEqual(ss.StorageSettings.load().s3_secret_key, "very-secret")
payload = self.client.get("/api/v1/company/administration/storage/").json()["storage"]
payload = self.client.get("/api/v1/instance/storage/").json()["storage"]
self.assertEqual(payload["backend"], "S3")
self.assertNotIn("s3SecretKey", payload)
self.assertTrue(payload["s3HasSecretKey"])
@@ -142,7 +144,7 @@ class StorageSettingsTests(TestCase):
def test_patch_to_s3_probes_before_switching(self) -> None:
with mock.patch.object(ss, "build_s3_storage", side_effect=RuntimeError("connection refused")):
response = self.client.patch(
"/api/v1/company/administration/storage/",
"/api/v1/instance/storage/",
{"backend": "S3", "s3Bucket": "demo", "s3AccessKey": "a", "s3SecretKey": "b"},
format="json",
)
@@ -153,7 +155,7 @@ class StorageSettingsTests(TestCase):
factory = _FakeS3Factory(self.bucket.name)
with mock.patch.object(ss, "build_s3_storage", factory):
response = self.client.patch(
"/api/v1/company/administration/storage/",
"/api/v1/instance/storage/",
{"backend": "S3", "s3Bucket": "demo", "s3EndpointUrl": "https://s3.example.com/", "s3AccessKey": "a", "s3SecretKey": "b"},
format="json",
)
@@ -164,11 +166,11 @@ class StorageSettingsTests(TestCase):
self.assertIsNotNone(row.s3_verified_at)
# Пустые секреты в повторном PATCH оставляют прежние.
with mock.patch.object(ss, "build_s3_storage", factory):
self.client.patch("/api/v1/company/administration/storage/", {"backend": "S3", "s3AccessKey": "", "s3SecretKey": ""}, format="json")
self.client.patch("/api/v1/instance/storage/", {"backend": "S3", "s3AccessKey": "", "s3SecretKey": ""}, format="json")
self.assertEqual(ss.StorageSettings.load().s3_secret_key, "b")
def test_check_endpoint_reports_errors_without_saving(self) -> None:
response = self.client.post("/api/v1/company/administration/storage/check/", {"s3Bucket": ""}, format="json")
response = self.client.post("/api/v1/instance/storage/check/", {"s3Bucket": ""}, format="json")
self.assertEqual(response.status_code, 400)
self.assertIn("s3Bucket", response.json()["errors"])
self.assertEqual(ss.StorageSettings.load().s3_bucket, "")
@@ -178,18 +180,18 @@ class StorageSettingsTests(TestCase):
OrganizationMembership.objects.create(organization=self.organization, user=employee, role=EmployeeRole.EMPLOYEE, position_title="Op")
client = TenantAPIClient()
client.force_authenticate(employee)
self.assertEqual(client.get("/api/v1/company/administration/storage/").status_code, 403)
self.assertEqual(client.patch("/api/v1/company/administration/storage/", {"backend": "LOCAL"}, format="json").status_code, 403)
self.assertEqual(client.get("/api/v1/instance/storage/").status_code, 403)
self.assertEqual(client.patch("/api/v1/instance/storage/", {"backend": "LOCAL"}, format="json").status_code, 403)
def test_migration_copies_local_files_to_s3(self) -> None:
with tenant_atomic(self.organization.id):
storages["default"].save(self.key, ContentFile(b"legacy"))
self._enable_s3()
response = self.client.post("/api/v1/company/administration/storage/migrate/")
response = self.client.post("/api/v1/instance/storage/migrate/")
self.assertEqual(response.status_code, 202, response.content)
row = ss.StorageSettings.load()
self.assertEqual(row.migration_status, ss.StorageMigrationStatus.RUNNING)
copied = migrate_local_to_s3(row)
self.assertEqual(copied, 1)
self.assertTrue((__import__("pathlib").Path(self.bucket.name) / self.key).exists())
self.assertEqual(self.client.post("/api/v1/company/administration/storage/migrate/").status_code, 409)
self.assertEqual(self.client.post("/api/v1/instance/storage/migrate/").status_code, 409)
+5 -7
View File
@@ -15,7 +15,6 @@ from chatballs.conversations.attachment_views import (
from chatballs.conversations.models import Message, MessageKind
from chatballs.conversations.voice_views import ALLOWED_AUDIO_TYPES, MAX_VOICE_BYTES
from chatballs.i18n import t
from chatballs.identity.models import Organization
from chatballs.integrations.features import voice_messages_allowed
from chatballs.integrations.models import IntegrationStatus
from chatballs.tenancy.context import TenantContext
@@ -25,6 +24,7 @@ from chatballs.tenancy.ingress import (
web_session_route,
web_widget_route,
)
from chatballs.tenancy.lookup import load_organization
from chatballs.webchat import services
from chatballs.webchat.api_inputs import host_origin, session_token
from chatballs.webchat.loader import LOADER_JS
@@ -58,9 +58,8 @@ def _resolved_web_widget(widget_key: str, channel_code: str = ""):
if route is None:
yield None, None
return
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist:
organization = load_organization(route.organization_id)
if organization is None:
yield None, None
return
context = TenantContext.for_resource(organization)
@@ -96,9 +95,8 @@ def _resolved_web_session(request: Request):
if route is None:
yield None, None
return
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist:
organization = load_organization(route.organization_id)
if organization is None:
yield None, None
return
context = TenantContext.for_resource(organization)
@@ -2,7 +2,7 @@ import os
from django.core.exceptions import ImproperlyConfigured
from chatballs_backend.settings_env import env_secret
from chatballs_backend.settings_env import env_bool, env_secret
def _credentials() -> tuple[dict[str, str], dict[str, str]]:
@@ -16,16 +16,19 @@ def _credentials() -> tuple[dict[str, str], dict[str, str]]:
"POSTGRES_MIGRATION_USER", "chatballs_migration"
),
}
# Пароли ролей генерирует первый старт стека в том с секретами; человек их
# не вводит и не хранит. Переменные окружения остаются переопределением.
fallback = env_secret("POSTGRES_PASSWORD", "postgres_password", "chatballs")
# Пароли ролей генерирует первый старт стека в томах с секретами; человек их
# не вводит и не хранит. Пароли platform и migration лежат в своих томах
# (подкаталоги platform/ и schema/), которые монтируются только процессам с
# этими ролями; у остальных файла нет и остаётся default. Переменные
# окружения остаются переопределением.
fallback = env_secret("POSTGRES_PASSWORD", "schema/postgres_password", "chatballs")
passwords = {
"app": env_secret("POSTGRES_APP_PASSWORD", "postgres_app_password", fallback),
"platform": env_secret(
"POSTGRES_PLATFORM_PASSWORD", "postgres_platform_password", fallback
"POSTGRES_PLATFORM_PASSWORD", "platform/postgres_platform_password", fallback
),
"migration": env_secret(
"POSTGRES_MIGRATION_PASSWORD", "postgres_migration_password", fallback
"POSTGRES_MIGRATION_PASSWORD", "schema/postgres_migration_password", fallback
),
}
return users, passwords
@@ -75,10 +78,13 @@ def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]:
"OPTIONS": {"pool": dict(pool)} if pool else {},
}
databases = {
"default": config("migration" if testing else role),
"platform": config("platform"),
}
databases = {"default": config("migration" if testing else role)}
# Алиас platform поднимается только там, где он нужен: в платформенной
# поверхности и в воркере (CHATBALLS_DB_PLATFORM_ALIAS=1 — он захватывает
# outbox всех организаций). Остальные процессы пароль этой роли не читают;
# обращение к алиасу там упадёт сразу, а не откроет обход изоляции.
if testing or role == "platform" or env_bool("CHATBALLS_DB_PLATFORM_ALIAS"):
databases["platform"] = config("platform")
if testing:
# Тесты создают свою БД и подключаются владельцем кластера. Его пароль
# приходит оттуда же, откуда у остальных ролей: файл секрета инстанса,
@@ -87,7 +93,7 @@ def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]:
"POSTGRES_USER", "chatballs_bootstrap"
)
databases["default"]["PASSWORD"] = env_secret(
"POSTGRES_PASSWORD", "postgres_password", "chatballs"
"POSTGRES_PASSWORD", "schema/postgres_password", "chatballs"
)
databases["platform"]["TEST"] = {"MIRROR": "default"}
return databases
@@ -13,6 +13,7 @@ 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")),
path(
"api/v1/demo-media/avatars/<str:name>",
DemoMediaView.as_view(),
+43 -4
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, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
import { AuthChangePassword, 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";
@@ -42,6 +42,14 @@ export function App() {
const [totpChallenge, setTotpChallenge] = useState<AuthChallenge | null>(null);
const [recovering, setRecovering] = useState(false);
const [resetting, setResetting] = useState(() => window.location.pathname === "/reset-password");
// Ссылка из письма-приглашения (/join?token=…): токен запоминается до входа
// и принимается, как только известна учётная запись.
const [joinToken, setJoinToken] = useState<string | null>(
() => (window.location.pathname === "/join" ? new URLSearchParams(window.location.search).get("token") : null),
);
// До входа ссылка ведёт либо на форму создания пароля (учётной записи ещё
// нет), либо на обычный вход — это решает предпросмотр приглашения.
const [joinNeedsLogin, setJoinNeedsLogin] = useState(false);
// Мастер первого запуска: пока в инстансе нет организации, вместо входа — форма создания.
const [needsSetup, setNeedsSetup] = useState(false);
const [data, setData] = useState<AppData>({ groups: [], agents: [] });
@@ -90,7 +98,8 @@ export function App() {
if (!payload.authenticated) acceptServerLanguage(payload.language);
if (payload.authenticated && payload.user) {
const activeUser = useIdentity(payload.user, initialRoute.organizationPublicId);
if (activeUser && !initialRoute.organizationPublicId) {
// Адрес без организации или с чужой: подставляем ту, что открылась.
if (activeUser && activeUser.organizationPublicId !== initialRoute.organizationPublicId) {
const nextPath = pathFromRoute(
initialRoute.route,
initialRoute.employeeId || initialRoute.agentId || initialRoute.knowledgeId || initialRoute.clientId || initialRoute.channelId || initialRoute.supportPortalId || initialRoute.portalSettingsSection || initialRoute.settingsSection,
@@ -133,6 +142,32 @@ export function App() {
useIdentity(nextIdentity, organizationPublicId);
}, [organizationPublicId, useIdentity]);
// Переключатель в сайдбаре: другая организация открывается со своего
// стартового экрана, Shell пересобирается по key — состояние страниц
// прежней организации в новую не переезжает.
const switchOrganization = useCallback((nextOrganizationId: string) => {
if (!identity) return;
const activeUser = useIdentity(identity, nextOrganizationId);
if (activeUser) {
navigate(defaultRoute(activeUser), null, false, activeUser.organizationPublicId);
}
}, [identity, navigate, useIdentity]);
const finishJoin = useCallback((nextIdentity: AuthenticatedUser, organizationId: string) => {
setJoinToken(null);
const activeUser = useIdentity(nextIdentity, organizationId);
if (activeUser) {
navigate(defaultRoute(activeUser), null, true, activeUser.organizationPublicId);
}
}, [navigate, useIdentity]);
const joinUseLogin = useCallback(() => setJoinNeedsLogin(true), []);
const cancelJoin = useCallback(() => {
setJoinToken(null);
if (user) navigate(defaultRoute(user), null, true, user.organizationPublicId);
}, [navigate, user]);
async function logout() {
await api("/api/v1/auth/logout/", { method: "POST" }).catch(() => undefined);
setIdentity(null);
@@ -160,13 +195,17 @@ export function App() {
{totpChallenge ? (
<AuthTotpCode challenge={totpChallenge} onVerified={(nextUser) => { setTotpChallenge(null); landAfterAuth(nextUser); }} />
) : !identity ? (
needsSetup ? (
joinToken && !joinNeedsLogin && !needsSetup ? (
<AuthJoinGuest token={joinToken} onUseLogin={joinUseLogin} onRegistered={finishJoin} />
) : needsSetup ? (
<AuthSetup onDone={(nextUser) => { setNeedsSetup(false); landAfterAuth(nextUser); }} />
) : recovering ? (
<AuthPasswordRecovery onBackToLogin={() => setRecovering(false)} />
) : (
<AuthLogin onLogin={landAfterAuth} onTotpChallenge={setTotpChallenge} onRecover={() => setRecovering(true)} />
)
) : joinToken ? (
<AuthJoin token={joinToken} onAccepted={finishJoin} onBack={cancelJoin} />
) : !user ? (
<PermissionScreen onReturn={logout} />
) : user.mustChangePassword ? (
@@ -178,7 +217,7 @@ export function App() {
) : !canAccess(user, navigation.route) ? (
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
) : (
<Shell 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} />
<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} />
)}
</ConfigProvider>
);
+1
View File
@@ -12,6 +12,7 @@ function userWith(role: Role, capabilities: string[] = []): SessionUser {
totpEnabled: false,
totpLastUsedAt: null,
deliveryMode: "CLOUD",
isInstanceAdmin: false,
uiTheme: "SYSTEM",
uiAccent: "",
uiLanguage: "",
+30 -6
View File
@@ -2,19 +2,43 @@ import type { AuthenticatedUser, SessionUser } from "../types";
const preferenceKey = "chatballs.organizationPublicId";
function storedPreference(): string | null {
try {
return window.localStorage.getItem(preferenceKey);
} catch {
return null;
}
}
function rememberPreference(publicId: string): void {
try {
window.localStorage.setItem(preferenceKey, publicId);
} catch {
// Хранилище недоступно (приватный режим, запрет сайта) — адрес всё равно
// несёт организацию, а без него откроется первая по списку.
}
}
/** Выбрать организацию сессии: из адреса, иначе последнюю открытую, иначе
* первую в списке членств (сервер отдаёт их по имени). Человек с несколькими
* организациями не упирается в «нет доступа» — он входит в одну из своих и
* меняет её переключателем в сайдбаре (дизайн-базлайн v2, A1). */
export function activateOrganization(
identity: AuthenticatedUser,
requestedPublicId: string | null,
): SessionUser | null {
const preferred = requestedPublicId || window.sessionStorage.getItem(preferenceKey);
const membership =
identity.memberships.find((item) => item.organizationPublicId === preferred) ||
(identity.memberships.length === 1 ? identity.memberships[0] : null);
const byId = (publicId: string | null) =>
publicId ? identity.memberships.find((item) => item.organizationPublicId === publicId) : undefined;
const membership = byId(requestedPublicId) || byId(storedPreference()) || identity.memberships[0];
if (!membership) return null;
window.sessionStorage.setItem(preferenceKey, membership.organizationPublicId);
rememberPreference(membership.organizationPublicId);
return { ...identity, ...membership };
}
export function clearOrganizationPreference(): void {
window.sessionStorage.removeItem(preferenceKey);
try {
window.localStorage.removeItem(preferenceKey);
} catch {
// См. rememberPreference.
}
}
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { api } from "../../api/client";
import { Icon } from "../../shared/icons";
import { AuthFrame } from "./AuthFrame";
import { t } from "../../i18n";
import type { AuthenticatedUser } from "../../types";
// Приглашение в организацию по ссылке из письма (/join?token=…): человек уже
// вошёл под своей учётной записью, приглашение принимается само, и приложение
// открывается в новой организации. Отказ сервера показывается его же словами.
type Status = "accepting" | "failed";
export function AuthJoin({ token, onAccepted, onBack }: {
token: string;
onAccepted: (user: AuthenticatedUser, organizationPublicId: string) => void;
onBack: () => void;
}) {
const [status, setStatus] = useState<Status>("accepting");
const [detail, setDetail] = useState("");
useEffect(() => {
let active = true;
api<{ user: AuthenticatedUser; organizationPublicId: string }>("/api/v1/auth/invitations/accept/", {
method: "POST",
body: JSON.stringify({ token }),
})
.then((payload) => { if (active) onAccepted(payload.user, payload.organizationPublicId); })
.catch((error) => {
if (!active) return;
setDetail(error instanceof Error ? error.message : "");
setStatus("failed");
});
return () => { active = false; };
}, [token, onAccepted]);
if (status === "accepting") {
return (
<AuthFrame title={t("auth.invitation_title")} subtitle={t("admin.one_moment")} logo="shield">
<div className="auth-card"><p className="auth-hint">{t("auth.invitation_accepting")}</p></div>
</AuthFrame>
);
}
const backLink = (
<button type="button" className="auth-back-login" onClick={onBack}><Icon name="arrow" size={14} />{t("profile.go_start_page")}</button>
);
return (
<AuthFrame title={t("auth.invitation_title")} subtitle={t("auth.invitation_not_accepted")} logo="shield" note={backLink}>
<div className="auth-card">
<div className="auth-recovery-sent">
<div className="auth-recovery-sent-icon warning"><Icon name="warning" size={26} /></div>
<h3>{t("auth.invitation_not_accepted")}</h3>
{detail && <p>{detail}</p>}
</div>
</div>
</AuthFrame>
);
}
@@ -0,0 +1,130 @@
import { type FormEvent, useEffect, useState } from "react";
import { api, ApiError } from "../../api/client";
import { Icon } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import { AuthField } from "./AuthField";
import { AuthFrame } from "./AuthFrame";
import { passwordIsValid, passwordLabel, passwordScore } from "./password";
import { t } from "../../i18n";
import type { AuthenticatedUser } from "../../types";
// Ссылка-приглашение без входа (/join?token=…). Сервер говорит, куда зовут и
// есть ли учётная запись: существующая идёт на обычный вход, новая задаёт имя
// и пароль прямо здесь — теми же полями, что в мастере первого запуска, — и
// сразу оказывается в организации.
type Preview = { valid: boolean; email?: string; organizationName?: string; accountExists?: boolean };
type Status = "checking" | "invalid" | "register";
type FieldErrors = { fullName?: string; password?: string };
export function AuthJoinGuest({ token, onUseLogin, onRegistered }: {
token: string;
onUseLogin: () => void;
onRegistered: (user: AuthenticatedUser, organizationPublicId: string) => void;
}) {
const [status, setStatus] = useState<Status>("checking");
const [preview, setPreview] = useState<Preview | null>(null);
const [fullName, setFullName] = useState("");
const [password, setPassword] = useState("");
const [show, setShow] = useState(false);
const [errors, setErrors] = useState<FieldErrors>({});
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
let active = true;
api<Preview>(`/api/v1/auth/invitations/preview/?token=${encodeURIComponent(token)}`)
.then((payload) => {
if (!active) return;
if (!payload.valid) { setStatus("invalid"); return; }
if (payload.accountExists) { onUseLogin(); return; }
setPreview(payload);
setStatus("register");
})
.catch(() => { if (active) setStatus("invalid"); });
return () => { active = false; };
}, [token, onUseLogin]);
const score = passwordScore(password);
const valid = fullName.trim() !== "" && passwordIsValid(password, false);
async function submit(event: FormEvent) {
event.preventDefault();
if (!valid || submitting) return;
setSubmitting(true);
setError("");
setErrors({});
try {
const payload = await api<{ user: AuthenticatedUser; organizationPublicId: string }>("/api/v1/auth/invitations/register/", {
method: "POST",
body: JSON.stringify({ token, fullName, password }),
});
onRegistered(payload.user, payload.organizationPublicId);
} catch (requestError) {
if (requestError instanceof ApiError) {
const fieldErrors = (requestError.payload as { errors?: FieldErrors }).errors;
if (fieldErrors) setErrors(fieldErrors);
setError(requestError.message);
} else {
setError(t("auth.invitation_not_accepted"));
}
} finally {
setSubmitting(false);
}
}
if (status === "checking") {
return (
<AuthFrame title={t("auth.invitation_title")} subtitle={t("admin.one_moment")} logo="shield">
<div className="auth-card"><p className="auth-hint">{t("admin.checking_link")}</p></div>
</AuthFrame>
);
}
if (status === "invalid") {
return (
<AuthFrame title={t("auth.invitation_title")} subtitle={t("auth.invitation_not_accepted")} logo="shield">
<div className="auth-card">
<div className="auth-recovery-sent">
<div className="auth-recovery-sent-icon warning"><Icon name="warning" size={26} /></div>
<h3>{t("admin.link_does_not_work")}</h3>
<p>{t("auth.invitation_invalid")}</p>
</div>
</div>
</AuthFrame>
);
}
return (
<AuthFrame
title={t("auth.invitation_title")}
subtitle={t("auth.join_subtitle", { organization: preview?.organizationName ?? "", email: preview?.email ?? "" })}
logo="shield"
width={420}
>
<form className="auth-card" onSubmit={submit}>
{error && <div className="auth-error"><span className="auth-error-dot">!</span><span>{error}</span></div>}
<label className="field-label">{t("admin.name")}</label>
<AuthField icon="user" value={fullName} onChange={setFullName} placeholder={t("admin.elena_kuznetsova")} error={Boolean(errors.fullName)} />
<label className="field-label">{t("common.password")}</label>
<AuthField icon="lock" value={password} onChange={setPassword} placeholder={t("common.at_least_10_characters")} type={show ? "text" : "password"} error={Boolean(errors.password)}>
<button type="button" onClick={() => setShow((value) => !value)} aria-label={show ? t("admin.hide_password") : t("admin.show_password")}>
<Icon name={show ? "eyeOff" : "eye"} size={17} />
</button>
</AuthField>
<div className={`password-strength score-${score}`}>
<div>{[0, 1, 2, 3].map((item) => <span className={item < score ? "active" : ""} key={item} />)}</div>
<p>{passwordLabel(score)}</p>
</div>
<div className="password-requirements">
<strong>{t("common.password_requirements")}</strong>
<span className={password.length >= 10 ? "done" : ""}>{t("common.at_least_10_characters_long")}</span>
<span className={/\d/.test(password) ? "done" : ""}>{t("common.contains_digit")}</span>
<span className={/[A-Za-zА-Яа-я]/.test(password) && /[^A-Za-zА-Яа-я0-9]/.test(password) ? "done" : ""}>{t("common.letters_special_character")}</span>
</div>
<Button className="auth-submit" type="submit" variant="primary" disabled={!valid || submitting}>{t("auth.join_accept")}</Button>
</form>
</AuthFrame>
);
}
@@ -1,4 +1,6 @@
export { AuthChangePassword } from "./AuthChangePassword";
export { AuthJoin } from "./AuthJoin";
export { AuthJoinGuest } from "./AuthJoinGuest";
export { AuthLogin } from "./AuthLogin";
export { AuthPasswordRecovery } from "./AuthPasswordRecovery";
export { AuthResetPassword } from "./AuthResetPassword";
@@ -0,0 +1,64 @@
import { Dropdown } from "antd";
import { useState } from "react";
import type { EmployeeGroup, EmployeeInvitation } from "../../types";
import { Icon } from "../../shared/icons";
import { Avatar } from "../../shared/ui";
import { groupColorOf } from "../conversations/model";
import { formatDate, invitationStatusBadge, roleAccessLabel, roleBadge } from "./model";
import { t } from "../../i18n";
// Строка ожидающего приглашения (кадры E1/E2, статус «Приглашён»): та же
// разметка, что у сотрудника, — роль, должность и группы уже известны, они
// придут вместе с членством после принятия. Карточки у приглашения нет,
// поэтому строка не открывается; меню — отправить ещё раз или отозвать.
export function EmployeeInvitationRow({ invitation, groups, canManage, onResend, onRevoke }: {
invitation: EmployeeInvitation;
groups: EmployeeGroup[];
canManage: boolean;
onResend: (invitation: EmployeeInvitation) => void;
onRevoke: (invitation: EmployeeInvitation) => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const role = roleBadge(invitation.role);
const status = invitationStatusBadge();
const act = (run: () => void) => { setMenuOpen(false); run(); };
const menuItems = [
{ key: "resend", disabled: !canManage, label: <button type="button" onClick={() => act(() => onResend(invitation))}><Icon name="mail" size={15} />{t("admin.resend_invitation")}</button> },
{ type: "divider" as const },
{ key: "revoke", disabled: !canManage, label: <button className="danger" type="button" onClick={() => act(() => onRevoke(invitation))}><Icon name="xCircle" size={15} />{t("admin.revoke_invitation")}</button> },
];
return (
<div className="employees-row is-invited">
<div className="employees-person">
<Avatar employee={{ ...invitation, isActive: true, isBlocked: false, mustChangePassword: true, totpRequired: false, totpEnabled: false }} />
<span>
<strong>{invitation.fullName || invitation.email}</strong>
<small>{invitation.email}</small>
</span>
</div>
<b className="employees-badge" style={{ background: role.bg, color: role.color }}>{role.text}</b>
<span className="employees-position">{invitation.positionTitle}</span>
<div className="employees-groups">
{invitation.groups.length === 0
? <span className="employees-nogroup">{t("common.no_group")}</span>
: invitation.groups.map((group) => (
<span className="employees-group" key={group.id}>
<i style={{ background: groupColorOf(group.id, groups.find((item) => item.id === group.id)?.color) }} />
{group.name}
</span>
))}
</div>
<span className={`employees-access ${invitation.role === "EMPLOYEE" ? "is-limited" : ""}`}>{roleAccessLabel(invitation)}</span>
<b className="employees-badge has-dot" style={{ background: status.bg, color: status.color }}><i />{status.text}</b>
<span className="employees-login">{t("admin.invitation_valid_until", { date: formatDate(invitation.expiresAt) })}</span>
<Dropdown menu={{ items: menuItems }} open={menuOpen} onOpenChange={setMenuOpen} trigger={["click"]} overlayClassName="app-dropdown is-employee-menu">
<button className={`employees-row-menu ${menuOpen ? "is-open" : ""}`} type="button" aria-label={t("common.actions_for", { name: invitation.fullName || invitation.email })} title={t("common.actions")} onClick={(event) => event.stopPropagation()}>
<Icon name="more" size={16} />
</button>
</Dropdown>
</div>
);
}
@@ -1,9 +1,10 @@
import { Dropdown } from "antd";
import type { Employee, EmployeeGroup } from "../../types";
import type { Employee, EmployeeGroup, EmployeeInvitation } from "../../types";
import { Icon } from "../../shared/icons";
import { Avatar, EmptyState } from "../../shared/ui";
import { groupColorOf } from "../conversations/model";
import { EmployeeInvitationRow } from "./EmployeeInvitationRow";
import { employeeAvatarColor, formatLastLogin, roleAccessLabel, roleBadge, statusBadge } from "./model";
import { t } from "../../i18n";
@@ -12,21 +13,30 @@ import { t } from "../../i18n";
export function EmployeeTable({
employees,
invitations = [],
canManageInvitations = false,
groups,
menuId,
onBlock,
onResetPassword,
onTerminateSessions,
onResendInvitation,
onRevokeInvitation,
openEmployee,
setMenuId,
total,
}: {
employees: Employee[];
// Ожидающие приглашения — строками со статусом «Приглашён» перед сотрудниками.
invitations?: EmployeeInvitation[];
canManageInvitations?: boolean;
groups: EmployeeGroup[];
menuId: number | null;
onBlock: (employee: Employee) => void;
onResetPassword: (employee: Employee) => void;
onTerminateSessions: (employee: Employee) => void;
onResendInvitation?: (invitation: EmployeeInvitation) => void;
onRevokeInvitation?: (invitation: EmployeeInvitation) => void;
openEmployee: (employee: Employee) => void;
setMenuId: (id: number | null) => void;
total: number;
@@ -43,6 +53,16 @@ export function EmployeeTable({
<span>{t("admin.last_sign")}</span>
<span />
</div>
{invitations.map((invitation) => (
<EmployeeInvitationRow
invitation={invitation}
groups={groups}
canManage={canManageInvitations}
onResend={(item) => onResendInvitation?.(item)}
onRevoke={(item) => onRevokeInvitation?.(item)}
key={`invitation-${invitation.id}`}
/>
))}
{employees.map((employee) => (
<EmployeeRow
employee={employee}
@@ -56,7 +76,7 @@ export function EmployeeTable({
key={employee.id}
/>
))}
{!employees.length && <EmptyState title={t("admin.no_operators_found")} />}
{!employees.length && !invitations.length && <EmptyState title={t("admin.no_operators_found")} />}
<div className="employees-foot">
<small>{t("common.shown_of", { shown: employees.length, total })}</small>
<small>{t("admin.group_decides_which_conversations_visible_2")}</small>
@@ -11,7 +11,7 @@ import { EmployeeCreateDrawer } from "./EmployeeCreateDrawer";
import { EmployeePasswordDialog } from "./EmployeePasswordDialog";
import { EmployeeTable } from "./EmployeeTable";
import { EmployeesFilters } from "./EmployeesFilters";
import { blockEmployee, fetchEmployees, resetEmployeePassword, terminateEmployeeSessions, type IssuedPassword } from "./api";
import { blockEmployee, fetchEmployees, resendInvitation, resetEmployeePassword, revokeInvitation, terminateEmployeeSessions, type IssuedPassword } from "./api";
import type { EmployeeRoleFilter } from "./model";
import { t } from "../../i18n";
@@ -82,6 +82,10 @@ export function EmployeesPage({ groups, openEmployee, user }: {
<EmployeeTable
employees={employees.items}
invitations={employees.page === 1 ? employees.payload?.invitations ?? [] : []}
canManageInvitations={canManage}
onResendInvitation={(invitation) => void run(() => resendInvitation(invitation.id))}
onRevokeInvitation={(invitation) => void run(() => revokeInvitation(invitation.id))}
groups={groups}
menuId={menuId}
onBlock={(employee) => void run(() => blockEmployee(employee.id, !employee.isBlocked))}
+22 -5
View File
@@ -1,6 +1,6 @@
import { api } from "../../api/client";
import type { PagedPayload } from "../../shared/usePagedResource";
import type { Employee, Role } from "../../types";
import type { Employee, EmployeeInvitation, Role } from "../../types";
import type { EmployeeRoleFilter } from "./model";
// Действия над сотрудником (дизайн-базлайн v2, кадры E2, E3, E5–E8).
@@ -26,11 +26,16 @@ export type EmployeeCreateInput = {
};
export async function createEmployee(input: EmployeeCreateInput): Promise<IssuedPassword | null> {
const payload = await api<{ employee: Employee; password: string | null }>("/api/v1/employees/operators/", {
// Если адрес уже принадлежит учётной записи из другой организации, сервер
// вместо сотрудника выписывает приглашение: employee и password пусты,
// человек появится в списке после того, как примет его.
const payload = await api<{ employee: Employee | null; password: string | null; invited?: boolean }>("/api/v1/employees/operators/", {
method: "POST",
body: JSON.stringify(input),
});
return payload.password ? { employee: payload.employee, password: payload.password, reason: "created" } : null;
return payload.password && payload.employee
? { employee: payload.employee, password: payload.password, reason: "created" }
: null;
}
export async function resetEmployeePassword(userId: number, mode: PasswordMode): Promise<IssuedPassword | null> {
@@ -56,15 +61,27 @@ export type EmployeeListQuery = {
query: string;
};
// Вместе со страницей сервер отдаёт ожидающие приглашения — их мало и они
// не листаются.
export type EmployeesPayload = PagedPayload<Employee> & { invitations?: EmployeeInvitation[] };
export function fetchEmployees(
{ role, groupId, query }: EmployeeListQuery,
page: number,
): Promise<PagedPayload<Employee>> {
): Promise<EmployeesPayload> {
const params = new URLSearchParams({ page: String(page) });
if (role !== "all") params.set("role", role);
if (groupId !== "all") params.set("group", String(groupId));
if (query.trim()) params.set("q", query.trim());
return api<PagedPayload<Employee>>(`/api/v1/employees/?${params.toString()}`);
return api<EmployeesPayload>(`/api/v1/employees/?${params.toString()}`);
}
export async function resendInvitation(invitationId: number): Promise<void> {
await api(`/api/v1/employees/invitations/${invitationId}/resend/`, { method: "POST" });
}
export async function revokeInvitation(invitationId: number): Promise<void> {
await api(`/api/v1/employees/invitations/${invitationId}/revoke/`, { method: "POST" });
}
/** Кандидаты на владение (кадр E9) — только действующие администраторы.
@@ -59,6 +59,10 @@ export function statusBadge(employee: Employee) {
return STATUS_BADGE[employeeStatusKey(employee)];
}
export function invitationStatusBadge() {
return STATUS_BADGE.invited;
}
export function employeeStatusKey(employee: Employee): EmployeeStatus {
if (employee.isBlocked) return "blocked";
if (employee.mustChangePassword) return "invited";
@@ -83,7 +87,7 @@ export function groupsLabel(employee: Employee) {
return employee.groups.map((group) => group.name).join(", ");
}
export function roleAccessLabel(employee: Employee) {
export function roleAccessLabel(employee: { role: Role }) {
if (employee.role === "OWNER" || employee.role === "ADMIN") return t("common.full_access");
return t("admin.chat_only");
}
@@ -29,7 +29,9 @@ type Flag = "voiceMessages" | "audioCalls" | "videoCalls";
const BASE = "/api/v1/company/administration/communication/";
const COLUMNS: Array<[Flag, string]> = [["voiceMessages", t("settings.voice_messages")], ["audioCalls", t("settings.audio_calls")], ["videoCalls", t("settings.video_calls")]];
export function CommunicationSettingsCard({ canManage }: { canManage: boolean }) {
// Relay — настройка установки, а не организации: менять его может только
// администратор установки, поэтому право приходит отдельным флагом.
export function CommunicationSettingsCard({ canManage, canManageRelay }: { canManage: boolean; canManageRelay: boolean }) {
const [items, setItems] = useState<EntryPoint[] | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
const [errorText, setErrorText] = useState("");
@@ -85,7 +87,7 @@ export function CommunicationSettingsCard({ canManage }: { canManage: boolean })
{items.length > 0 && (
<p className="settings-section-note">{t("settings.voice_messages_sent_by_customer")}</p>
)}
<CallsRelayCard canManage={canManage} />
<CallsRelayCard canManage={canManageRelay} />
</>
);
}
@@ -17,7 +17,7 @@ import { CommunicationSettingsCard } from "./CommunicationSettingsCard";
import { PlatformSettingsCard } from "./PlatformSettingsCard";
import { StorageSettingsCard } from "./StorageSettingsCard";
import { GroupsSettingsCard } from "./GroupsSettingsCard";
import { DEFAULT_SETTINGS_SECTION, SETTINGS_SECTIONS, type SettingsSectionKey } from "./sections";
import { DEFAULT_SETTINGS_SECTION, visibleSettingsSections, type SettingsSectionKey } from "./sections";
import { useIntegrations } from "./useIntegrations";
import { t } from "../../i18n";
@@ -54,7 +54,9 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
integrations: connections.length,
};
const active = section ?? (mobile ? null : DEFAULT_SETTINGS_SECTION);
const current = SETTINGS_SECTIONS.find((item) => item.key === active) ?? null;
// Разделы про саму установку видит только её администратор.
const sections = visibleSettingsSections(user);
const current = sections.find((item) => item.key === active) ?? null;
const links = (
<>
@@ -70,7 +72,7 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
<nav className={`settings-subnav ${mobile && current ? "is-hidden" : ""}`}>
<div className="settings-subnav-head"><h2>{t("common.settings")}</h2></div>
<div className="settings-subnav-list">
{SETTINGS_SECTIONS.map((item) => (
{sections.map((item) => (
<button
className={`settings-subnav-item ${item.key === active ? "is-active" : ""}`}
key={item.key}
@@ -153,9 +155,9 @@ function SectionBody({ section, user, onUserUpdated, reload, groups, integration
}): ReactNode {
if (section === "organization") return <OrganizationSection user={user} onUserUpdated={onUserUpdated} />;
if (section === "groups") return <GroupsSettingsCard groups={groups} reload={reload} />;
if (section === "communication") return <CommunicationSettingsCard canManage={canManageSettings(user)} />;
if (section === "platform") return <PlatformSettingsCard canManage={canManageSettings(user)} />;
if (section === "storage") return <StorageSettingsCard canManage={canManageSettings(user)} />;
if (section === "communication") return <CommunicationSettingsCard canManage={canManageSettings(user)} canManageRelay={user.isInstanceAdmin} />;
if (section === "platform") return <PlatformSettingsCard canManage={user.isInstanceAdmin} />;
if (section === "storage") return <StorageSettingsCard canManage={user.isInstanceAdmin} />;
if (section === "demo") return <DemoDataCard reload={reload} />;
if (integrations.loading) return <LoadingState />;
if (integrations.failed) return <EmptyState title={t("settings.could_not_load_integrations")} />;
@@ -37,7 +37,7 @@ type Draft = {
s3AddressingStyle: string;
};
const BASE = "/api/v1/company/administration/storage/";
const BASE = "/api/v1/instance/storage/";
function draftOf(payload: StoragePayload): Draft {
return {
@@ -3,6 +3,8 @@ import { t } from "../../i18n";
// Настройки инсталляции (раздел «Платформа» и карточка relay в «Голосовых и
// звонках»). Один эндпоинт на всю установку: адрес, исходящая почта и TURN.
// Путь без организации: менять его вправе только администратор установки,
// читать — менеджер любой организации.
// PATCH частичный — блоки email и turn применяются, только если пришли в теле,
// поэтому каждая карточка сохраняет свою часть и не затирает соседние.
@@ -28,7 +30,7 @@ export type InstancePayload = {
turn: { urls: string[]; ttlSeconds: number; secretReady: boolean };
};
export const INSTANCE_BASE = "/api/v1/company/administration/instance/";
export const INSTANCE_BASE = "/api/v1/instance/settings/";
export function loadInstance(): Promise<InstancePayload> {
return api<{ instance: InstancePayload }>(INSTANCE_BASE).then((payload) => payload.instance);
@@ -1,4 +1,5 @@
import type { Icon } from "../../shared/icons";
import type { SessionUser } from "../../types";
import { t } from "../../i18n";
// Разделы «Настроек» (дизайн-базлайн v2, кадры N1–N7): субменю 240px и один
@@ -21,6 +22,8 @@ export type SettingsSection = {
icon: Parameters<typeof Icon>[0]["name"];
heading: string;
lead: string;
// Раздел про саму установку: виден только администратору установки.
instanceOnly?: boolean;
};
export const SETTINGS_SECTIONS: SettingsSection[] = [
@@ -67,6 +70,7 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
icon: "database",
heading: t("settings.file_storage"),
lead: t("settings.attachments_voice_messages_photos_logos"),
instanceOnly: true,
},
{
key: "platform",
@@ -74,6 +78,7 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
icon: "globe",
heading: t("settings.platform"),
lead: t("settings.properties_installation_itself_address_opened"),
instanceOnly: true,
},
{
key: "demo",
@@ -86,6 +91,10 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
export const DEFAULT_SETTINGS_SECTION: SettingsSectionKey = "organization";
export function visibleSettingsSections(user: SessionUser): SettingsSection[] {
return SETTINGS_SECTIONS.filter((section) => !section.instanceOnly || user.isInstanceAdmin);
}
export function settingsSectionKey(value: string | null | undefined): SettingsSectionKey | null {
const found = SETTINGS_SECTIONS.find((section) => section.key === value);
return found ? found.key : null;
+10
View File
@@ -124,6 +124,9 @@ export const en: Record<MessageKey, Message> = {
"admin.off_operator": "Off for this operator",
"admin.one_moment": "One moment.",
"admin.open_card": "Open the card",
"admin.resend_invitation": "Resend the invitation",
"admin.revoke_invitation": "Revoke the invitation",
"admin.invitation_valid_until": "Invited until {date}",
"admin.operator_blocked": "Operator blocked",
"admin.operator_blocked_cannot_sign_unblocking": "The operator is blocked and cannot sign in. Unblocking restores access according to their active assignments.",
"admin.operator_created": "Operator created",
@@ -1042,6 +1045,13 @@ export const en: Record<MessageKey, Message> = {
"profile.getting_started": "Getting started",
"profile.getting_started_progress": "{done} of {total}",
"profile.go_start_page": "Go to the start page",
"profile.switch_organization": "Switch organization",
"auth.invitation_title": "Organization invitation",
"auth.invitation_accepting": "Accepting the invitation…",
"auth.invitation_not_accepted": "The invitation was not accepted",
"auth.invitation_invalid": "The invitation is invalid or has expired",
"auth.join_subtitle": "You are invited to \u201c{organization}\u201d as {email}. Enter your name and set a password to sign in.",
"auth.join_accept": "Accept the invitation",
"profile.groups_list": "· groups: {groups}",
"profile.in_organization_since": "· in the organization since {date}",
"profile.interface_language": "Interface language",
+10
View File
@@ -125,6 +125,9 @@ export const ru = {
"admin.off_operator": "Отключена для этого сотрудника",
"admin.one_moment": "Подождите немного.",
"admin.open_card": "Открыть карточку",
"admin.resend_invitation": "Отправить приглашение ещё раз",
"admin.revoke_invitation": "Отозвать приглашение",
"admin.invitation_valid_until": "Приглашение до {date}",
"admin.operator_blocked": "Сотрудник заблокирован",
"admin.operator_blocked_cannot_sign_unblocking": "Сотрудник заблокирован и не может войти. Разблокировка восстановит доступ по активным назначениям.",
"admin.operator_created": "Сотрудник создан",
@@ -1043,6 +1046,13 @@ export const ru = {
"profile.getting_started": "Запуск",
"profile.getting_started_progress": "{done} из {total}",
"profile.go_start_page": "На главную",
"profile.switch_organization": "Переключить организацию",
"auth.invitation_title": "Приглашение в организацию",
"auth.invitation_accepting": "Принимаем приглашение…",
"auth.invitation_not_accepted": "Приглашение не принято",
"auth.invitation_invalid": "Приглашение недействительно или истекло",
"auth.join_subtitle": "Вас приглашают в «{organization}» как {email}. Укажите имя и задайте пароль для входа.",
"auth.join_accept": "Принять приглашение",
"profile.groups_list": "· группы: {groups}",
"profile.in_organization_since": "· в организации с {date}",
"profile.interface_language": "Язык интерфейса",
+3 -3
View File
@@ -13,8 +13,8 @@ import { DemoInstallBanner } from "../features/settings/DemoInstallBanner";
import { Sidebar } from "./Sidebar";
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 }: { 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 }) {
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 }) {
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [notifOpen, setNotifOpen] = useState(false);
@@ -112,7 +112,7 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
const isPortals = route === "supportPortals" || route === "supportPortalDetail" || route === "supportPortalSettings";
return (
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""} ${isSettings ? "is-settings-route" : ""} ${isProfile ? "is-profile-route" : ""} ${isContacts ? "is-contacts-route" : ""}`}>
<Sidebar route={route} user={user} setRoute={setRoute} openSettings={openSettingsRoute} onLogout={onLogout} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<Sidebar route={route} user={user} setRoute={setRoute} openSettings={openSettingsRoute} onLogout={onLogout} onSwitchOrganization={onSwitchOrganization} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<div className="hub-main">
{manager && <DemoInstallBanner reload={reload} />}
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
+24 -4
View File
@@ -47,6 +47,7 @@ export function Sidebar({
setRoute,
openSettings,
onLogout,
onSwitchOrganization,
waitingCount = 0,
chatScope,
setChatScope,
@@ -61,6 +62,7 @@ export function Sidebar({
setRoute: (route: RouteKey) => void;
openSettings: (section: SettingsSectionKey | null) => void;
onLogout: () => void;
onSwitchOrganization: (organizationPublicId: string) => void;
waitingCount?: number;
chatScope: DialogScope;
setChatScope: (scope: DialogScope) => void;
@@ -114,15 +116,33 @@ export function Sidebar({
? <img src={user.organizationLogoUrl} alt="" />
: <LogoIcon />}
</button>
{/* Переключатель организации (дизайн-базлайн v2, A1): пока у пользователя
одна организация — в списке она одна, отмечена. */}
{/* Переключатель организации (дизайн-базлайн v2, A1): все организации
человека, текущая отмечена; выбор другой открывает её стартовый экран. */}
<Dropdown
trigger={["click"]}
placement="bottomLeft"
overlayClassName="app-dropdown is-wide"
menu={{ items: [{ key: "current", label: <button type="button" className="is-checked"><Icon name="building" size={15} />{user.organizationName || "Chatballs"}</button> }] }}
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>
),
})),
}}
>
<button className="hub-brand-switch" type="button">
<button className="hub-brand-switch" type="button" title={t("profile.switch_organization")}>
<span>{user.organizationName || "Chatballs"}</span>
<Icon name="chevron" size={14} />
</button>
+18
View File
@@ -42,6 +42,9 @@ export type AuthenticatedUser = {
// Когда последний раз принимался код аутентификатора (кадр P1).
totpLastUsedAt: string | null;
deliveryMode: DeliveryMode;
// Администратор установки: разделы «Платформа» и «Хранилище файлов» — его,
// это свойства инсталляции, а не организации.
isInstanceAdmin: boolean;
memberships: OrganizationMembership[];
uiTheme: "LIGHT" | "DARK" | "SYSTEM";
uiAccent: string;
@@ -100,6 +103,21 @@ export type Employee = {
auditEvents?: EmployeeAuditEvent[];
};
// Ожидающее приглашение существующей учётной записи (статус «Приглашён»):
// роль, должность и группы уже известны и придут вместе с членством.
export type EmployeeInvitation = {
id: number;
email: string;
fullName: string;
avatarUrl?: string | null;
role: Role;
positionTitle: string;
phone: string;
groups: EmployeeGroupRef[];
invitedAt: string;
expiresAt: string;
};
export type EmployeeAuditEvent = {
action: string;
result: string;
+20 -3
View File
@@ -35,6 +35,8 @@ services:
restart: "no"
volumes:
- chatballs-secrets:/run/chatballs/secrets
- chatballs-secrets-platform:/run/chatballs/secrets/platform
- chatballs-secrets-schema:/run/chatballs/secrets/schema
postgres:
image: ${CHATBALLS_POSTGRES_IMAGE:-chatballs-postgres:dev}
@@ -42,18 +44,20 @@ services:
environment:
POSTGRES_DB: ${POSTGRES_DB:-chatballs}
POSTGRES_USER: ${POSTGRES_USER:-chatballs_bootstrap}
POSTGRES_PASSWORD_FILE: /run/chatballs/secrets/postgres_password
POSTGRES_PASSWORD_FILE: /run/chatballs/secrets/schema/postgres_password
POSTGRES_APP_USER: ${POSTGRES_APP_USER:-chatballs_app}
POSTGRES_APP_PASSWORD_FILE: /run/chatballs/secrets/postgres_app_password
POSTGRES_PLATFORM_USER: ${POSTGRES_PLATFORM_USER:-chatballs_platform}
POSTGRES_PLATFORM_PASSWORD_FILE: /run/chatballs/secrets/postgres_platform_password
POSTGRES_PLATFORM_PASSWORD_FILE: /run/chatballs/secrets/platform/postgres_platform_password
POSTGRES_MIGRATION_USER: ${POSTGRES_MIGRATION_USER:-chatballs_migration}
POSTGRES_MIGRATION_PASSWORD_FILE: /run/chatballs/secrets/postgres_migration_password
POSTGRES_MIGRATION_PASSWORD_FILE: /run/chatballs/secrets/schema/postgres_migration_password
depends_on:
secrets:
condition: service_completed_successfully
volumes:
- chatballs-secrets:/run/chatballs/secrets:ro
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
- chatballs-secrets-schema:/run/chatballs/secrets/schema:ro
- chatballs-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
@@ -81,6 +85,7 @@ services:
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
volumes:
- chatballs-secrets:/run/chatballs/secrets:ro
- chatballs-secrets-schema:/run/chatballs/secrets/schema:ro
command: ["python", "manage.py", "migrate", "--noinput"]
environment:
CHATBALLS_DB_ROLE: migration
@@ -103,6 +108,8 @@ services:
environment:
CHATBALLS_DB_ROLE: app
volumes:
# Только общий том: паролей platform и migration у публичного процесса
# нет даже в файловой системе (они в своих томах, см. сервис secrets).
- chatballs-secrets:/run/chatballs/secrets:ro
# Локальные файлы — именованный том, а не каталог с хоста. Том наследует
# владельца из образа (hub), поэтому загрузки работают и на Linux, где
@@ -132,6 +139,7 @@ services:
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
volumes:
- chatballs-secrets:/run/chatballs/secrets:ro
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
restart: unless-stopped
command: >
sh -c "gunicorn chatballs_backend.asgi_platform:application
@@ -167,6 +175,7 @@ services:
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
volumes:
- chatballs-secrets:/run/chatballs/secrets:ro
- chatballs-secrets-schema:/run/chatballs/secrets/schema:ro
restart: unless-stopped
command: >
sh -c "gunicorn chatballs_backend.asgi_admin:application
@@ -194,8 +203,12 @@ services:
command: ["python", "manage.py", "run_worker"]
environment:
CHATBALLS_DB_ROLE: app
# Захват outbox идёт по всем организациям сразу — воркеру нужен алиас
# platform. У backend-app его нет: он работает только ролью app.
CHATBALLS_DB_PLATFORM_ALIAS: "1"
volumes:
- chatballs-secrets:/run/chatballs/secrets:ro
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
- chatballs-media:/app/apps/backend/media
depends_on:
backend-app:
@@ -306,7 +319,11 @@ services:
volumes:
# Секреты инстанса: генерируются при первом старте, живут только здесь.
# Три тома, чтобы каждый процесс видел лишь свои пароли: общий, роли
# platform и роли миграций (вместе с паролем владельца кластера).
chatballs-secrets:
chatballs-secrets-platform:
chatballs-secrets-schema:
# Состояние установки. Именованные тома вместо каталогов с хоста: установка
# не зависит от того, из какого каталога её запустили, и не упирается в
# владельца каталога на хосте.
+13
View File
@@ -112,6 +112,19 @@ cmd_doctor() {
_doctor_report 0 "compose config invalid"
fi
# Разделение томов с секретами: у публичного backend-app не должно быть
# паролей ролей platform и migration даже в файловой системе. Проверяется
# только на работающем стеке; до первого запуска проверять нечего.
if run_compose ps --status running --services 2>/dev/null | grep -qx backend-app; then
if run_compose exec -T backend-app sh -c \
'for f in platform/postgres_platform_password schema/postgres_migration_password schema/postgres_password postgres_platform_password postgres_migration_password postgres_password; do test ! -s "/run/chatballs/secrets/$f" || exit 1; done' \
>/dev/null 2>&1; then
_doctor_report 1 "backend-app sees no platform/migration database passwords"
else
_doctor_report 0 "backend-app can read platform or migration database passwords"
fi
fi
if [[ "$failures" != "0" ]]; then
die "doctor: $failures check(s) failed" 1
fi
+11 -3
View File
@@ -2,6 +2,14 @@ server {
listen 80;
server_name _;
# Имена сервисов разрешаются на каждом запросе через DNS Docker, а не один
# раз при старте nginx: после пересоздания контейнера (compose up, обновление
# образа) адрес меняется, и закэшированный IP уводил бы трафик на другой
# сервис. Для этого upstream задан переменной — с ней nginx резолвит заново
# по истечении valid.
resolver 127.0.0.11 valid=10s ipv6=off;
set $backend_app backend-app:8000;
root /usr/share/nginx/html;
index index.html;
@@ -15,7 +23,7 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
location = /chat-widget.js {
proxy_pass http://backend-app:8000/chat-widget.js;
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -32,7 +40,7 @@ server {
}
location /api/ {
proxy_pass http://backend-app:8000/api/;
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -42,7 +50,7 @@ server {
# WebSocket signaling звонков (SPEC-CHATBALLS-0013 §9).
location /ws/ {
proxy_pass http://backend-app:8000/ws/;
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+18 -7
View File
@@ -1,3 +1,8 @@
# Имена сервисов разрешаются на каждом запросе через DNS Docker, а не один
# раз при старте nginx: после пересоздания контейнера (compose up, обновление
# образа) адрес меняется, и закэшированный IP уводил бы трафик на другой
# сервис. Для этого upstream задан переменной — с ней nginx резолвит заново
# по истечении valid.
# Vite HMR ходит по websocket на тот же origin, что и приложение, поэтому
# location / должен уметь апгрейдить соединение. Обычные запросы при этом
# закрывают connection как раньше.
@@ -9,6 +14,10 @@ map $http_upgrade $connection_upgrade {
server {
listen 80;
server_name app.localhost localhost *.localhost;
resolver 127.0.0.11 valid=10s ipv6=off;
set $backend_app backend-app:8000;
set $web_chat web-chat:5175;
set $frontend frontend:5173;
location = /admin {
return 404;
@@ -19,14 +28,14 @@ server {
}
location /api/ {
proxy_pass http://backend-app:8000;
proxy_pass http://$backend_app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws/ {
proxy_pass http://backend-app:8000;
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
@@ -35,14 +44,14 @@ server {
}
location = /chat-widget.js {
proxy_pass http://backend-app:8000;
proxy_pass http://$backend_app;
proxy_set_header Host $host;
}
location /chat/ {
# Vite React Refresh injects an inline preamble in local development.
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors *" always;
proxy_pass http://web-chat:5175;
proxy_pass http://$web_chat;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@@ -54,7 +63,7 @@ server {
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
add_header Permissions-Policy "camera=(self), microphone=(self)" always;
rewrite ^ /chat/call.html break;
proxy_pass http://web-chat:5175;
proxy_pass http://$web_chat;
proxy_set_header Host $host;
}
@@ -63,7 +72,7 @@ server {
# local-only; production keeps the strict CSP from the canonical gateway.
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
add_header X-Frame-Options SAMEORIGIN always;
proxy_pass http://frontend:5173;
proxy_pass http://$frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
@@ -76,9 +85,11 @@ server {
server {
listen 80;
server_name platform.localhost;
resolver 127.0.0.11 valid=10s ipv6=off;
set $backend_platform backend-platform:8000;
location /api/ {
proxy_pass http://backend-platform:8000;
proxy_pass http://$backend_platform;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
+61 -11
View File
@@ -1,19 +1,33 @@
#!/bin/sh
# Секреты инстанса генерируются один раз при первом старте и живут в томе
# chatballs-secrets. В .env, в репозитории и в руках человека их нет: установка
# Секреты инстанса генерируются один раз при первом старте и живут в томах
# chatballs-secrets*. В .env, в репозитории и в руках человека их нет: установка
# — одна команда, всё остальное настраивается в UI.
#
# Томов три, и каждый процесс монтирует только свои:
# $DIR — общие: ключ подписи, ключ шифрования, пароль роли app,
# секрет TURN (том chatballs-secrets);
# $DIR/platform — пароль роли platform (chatballs-secrets-platform):
# платформенная поверхность и воркер;
# $DIR/schema — пароль роли миграций и владельца кластера
# (chatballs-secrets-schema): postgres, init, admin.
# Публичный backend-app видит только общий том — пароли, дающие обход RLS,
# ему недоступны даже при компрометации процесса.
#
# Скрипт идемпотентен: существующие файлы не трогает, поэтому перезапуск и
# обновление стека не меняют пароли уже работающей базы.
# обновление стека не меняют пароли уже работающей базы. Установки, где
# пароли лежали плоско в общем томе, переносятся сюда же: файл копируется в
# свой том, сверяется и только потом удаляется из общего.
set -eu
DIR="${CHATBALLS_SECRETS_DIR:-/run/chatballs/secrets}"
mkdir -p "$DIR"
# Том виден только контейнерам стека, но читают его разные
PLATFORM_DIR="$DIR/platform"
SCHEMA_DIR="$DIR/schema"
mkdir -p "$DIR" "$PLATFORM_DIR" "$SCHEMA_DIR"
# Тома видны только контейнерам стека, но читают их разные
# пользователи (postgres, backend), поэтому права как у docker
# secrets: каталог 0755, файлы 0444. Права выставляются на каждом
# запуске — старые установки чинятся сами.
chmod 755 "$DIR"
chmod 755 "$DIR" "$PLATFORM_DIR" "$SCHEMA_DIR"
# 32 байта энтропии в hex. openssl есть в alpine/postgres-образах; /dev/urandom —
# запасной путь, если нет.
@@ -25,9 +39,33 @@ random_secret() {
fi
}
# Перенос плоского файла из общего тома в свой: только если в своём его ещё
# нет. Копия сверяется побайтно, и лишь после этого оригинал удаляется —
# обрыв на любом шаге оставляет как минимум один целый экземпляр.
relocate_secret() {
name="$1"
target_dir="$2"
source="$DIR/$name"
target="$target_dir/$name"
if [ -s "$target" ] || [ ! -s "$source" ]; then
return 0
fi
cp "$source" "$target"
if ! cmp -s "$source" "$target"; then
rm -f "$target"
echo "failed to relocate $name: copy mismatch" >&2
exit 1
fi
chmod 444 "$target"
chmod 644 "$source" 2>/dev/null || true
rm -f "$source"
echo "relocated $name to $(basename "$target_dir")/"
}
ensure_secret() {
name="$1"
file="$DIR/$name"
target_dir="${2:-$DIR}"
file="$target_dir/$name"
if [ -s "$file" ]; then
return 0
fi
@@ -37,14 +75,26 @@ ensure_secret() {
}
fix_permissions() {
chmod 444 "$DIR"/* 2>/dev/null || true
# Только файлы: каталоги platform/ и schema/ — точки монтирования своих
# томов, и без бита исполнения на них postgres и backend не войдут внутрь.
chmod 755 "$DIR" "$PLATFORM_DIR" "$SCHEMA_DIR"
for d in "$DIR" "$PLATFORM_DIR" "$SCHEMA_DIR"; do
for f in "$d"/*; do
[ -f "$f" ] && chmod 444 "$f"
done
done
return 0
}
relocate_secret postgres_platform_password "$PLATFORM_DIR"
relocate_secret postgres_migration_password "$SCHEMA_DIR"
relocate_secret postgres_password "$SCHEMA_DIR"
ensure_secret secret_key
ensure_secret postgres_password
ensure_secret postgres_password "$SCHEMA_DIR"
ensure_secret postgres_app_password
ensure_secret postgres_platform_password
ensure_secret postgres_migration_password
ensure_secret postgres_platform_password "$PLATFORM_DIR"
ensure_secret postgres_migration_password "$SCHEMA_DIR"
# Общий секрет TURN: его знают приложение и coturn. Человек его не вводит —
# иначе пришлось бы вписывать одно и то же значение в двух местах.
ensure_secret turn_secret
+3 -1
View File
@@ -27,7 +27,9 @@ export default defineConfig({
projects: [
{
name: "internal-ui",
use: { ...devices["Desktop Chrome"], baseURL: "http://localhost:5173" },
// Русская локаль браузера: интерфейс открывается на языке установки из
// моков без перезагрузки, которую иначе делает acceptServerLanguage.
use: { ...devices["Desktop Chrome"], baseURL: "http://localhost:5173", locale: "ru-RU" },
},
{
name: "web-chat-mobile",
+102 -2
View File
@@ -75,6 +75,10 @@ function identityFor(role: Role, memberships = [membershipFor(role)]) {
totpEnabled: false,
totpLastUsedAt: null,
deliveryMode: "SELF_HOSTED",
isInstanceAdmin: role === "OWNER",
// Язык отдаёт сервер: без него интерфейс открылся бы на языке браузера.
language: "ru",
uiLanguage: "",
memberships,
uiTheme: "LIGHT",
uiAccent: "#1677ff",
@@ -142,8 +146,24 @@ const OWNER_STAFF = {
// Пустое, но валидное окружение экрана: чат, справочники и чек-лист запуска.
// Без него любой экран падает в состояние ошибки и проверять на нём нечего.
// Ожидающее приглашение существующей учётной записи: строка со статусом
// «Приглашён» перед сотрудниками (кадры E1/E2).
const INVITATION = {
id: 9,
email: "guest@example.com",
fullName: "Пётр Приглашённый",
avatarUrl: null,
role: "EMPLOYEE",
positionTitle: "Оператор поддержки",
phone: "",
groups: [{ id: 1, name: "Операторы" }],
invitedAt: "2026-09-10T10:00:00Z",
expiresAt: "2026-09-17T10:00:00Z",
};
async function mockInstance(page: Page) {
await page.route("**/api/v1/setup/", (route) => route.fulfill({ json: { needsSetup: false } }));
await page.route("**/api/v1/instance/**", (route) => route.fulfill({ json: {} }));
await page.route("**/api/v1/organizations/*/conversations/**", (route) => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith("/conversations/counters/")) {
@@ -192,14 +212,14 @@ async function mockEmployees(page: Page) {
.filter((employee) => !role || employee.role === role)
.filter((employee) => !query || `${employee.fullName} ${employee.email} ${employee.positionTitle}`.toLowerCase().includes(query));
return route.fulfill({
json: { items, page: 1, pageSize: 20, total: items.length, pageCount: 1 },
json: { items, page: 1, pageSize: 20, total: items.length, pageCount: 1, invitations: [INVITATION] },
});
});
}
async function mockSession(page: Page, user: object | null) {
await page.route("**/api/v1/auth/session/", (route) =>
route.fulfill({ json: user ? { authenticated: true, user } : { authenticated: false } }),
route.fulfill({ json: user ? { authenticated: true, user } : { authenticated: false, language: "ru" } }),
);
}
@@ -279,6 +299,86 @@ test("организация выбирается адресом, а не сох
expect(requests.filter((url) => url.includes(ORGANIZATION_PUBLIC_ID))).toEqual([]);
});
test("ожидающее приглашение показано строкой «Приглашён» с меню отправки и отзыва", async ({ page }) => {
await mockSession(page, OWNER_IDENTITY);
await mockInstance(page);
await mockEmployees(page);
const posted: string[] = [];
await page.route("**/api/v1/organizations/*/employees/invitations/**", (route) => {
posted.push(new URL(route.request().url()).pathname);
return route.fulfill({ json: { ok: true } });
});
await page.goto(`/organizations/${ORGANIZATION_PUBLIC_ID}/employees`);
const row = page.locator(".employees-row.is-invited");
await expect(row).toHaveCount(1);
await expect(row.getByText("Пётр Приглашённый")).toBeVisible();
await expect(row.locator(".employees-badge.has-dot")).toHaveText("Приглашён");
await expect(row.getByText("Оператор поддержки")).toBeVisible();
await row.locator(".employees-row-menu").click();
await page.getByRole("button", { name: "Отправить приглашение ещё раз" }).click();
await expect.poll(() => posted).toContain(`/api/v1/organizations/${ORGANIZATION_PUBLIC_ID}/employees/invitations/9/resend/`);
});
test("гость по ссылке-приглашению задаёт имя и пароль и попадает в организацию", async ({ page }) => {
await mockSession(page, null);
await mockInstance(page);
await mockEmployees(page);
await page.route("**/api/v1/auth/invitations/preview/**", (route) =>
route.fulfill({ json: { valid: true, email: "new-owner@example.com", organizationName: "Новая организация", accountExists: false } }),
);
const registered: string[] = [];
await page.route("**/api/v1/auth/invitations/register/", async (route) => {
registered.push(route.request().postData() ?? "");
const user = identityFor("OWNER", [membershipFor("OWNER", { organizationPublicId: SECOND_ORGANIZATION_PUBLIC_ID, organization: "new", organizationName: "Новая организация" })]);
// После регистрации сессия уже есть — сессионный мок отвечает как для вошедшего.
await mockSession(page, user);
return route.fulfill({ status: 201, json: { authenticated: true, user, organizationPublicId: SECOND_ORGANIZATION_PUBLIC_ID } });
});
await page.goto("/join?token=guest-token");
await expect(page.getByText("Вас приглашают в «Новая организация»")).toBeVisible();
await page.getByPlaceholder("Елена Кузнецова").fill("Новый Владелец");
await page.getByPlaceholder("Минимум 10 символов").fill("Very-strong-passphrase-42");
await page.getByRole("button", { name: "Принять приглашение" }).click();
await expect(page).toHaveURL(new RegExp(`/organizations/${SECOND_ORGANIZATION_PUBLIC_ID}/`));
await expect(managerNav(page)).toHaveCount(MANAGER_NAV.length);
expect(registered[0]).toContain("guest-token");
});
test("с несколькими организациями вход открывает первую, переключатель ведёт во вторую", async ({ page }) => {
const secondMembership = membershipFor("OWNER", {
id: 3,
organizationPublicId: SECOND_ORGANIZATION_PUBLIC_ID,
organization: "second",
organizationName: "Вторая организация",
});
await mockInstance(page);
await mockEmployees(page);
await mockSession(page, identityFor("OWNER", [membershipFor("OWNER"), secondMembership]));
// Адрес без организации: открывается первая по списку, а не экран «нет доступа».
await page.goto("/");
await expect(managerNav(page)).toHaveCount(MANAGER_NAV.length);
await expect(page).toHaveURL(new RegExp(`/organizations/${ORGANIZATION_PUBLIC_ID}/`));
await expect(page.locator(".hub-brand-switch span")).toHaveText("Ателье Норд");
// Переключатель (A1): в списке обе организации, текущая отмечена.
await page.locator(".hub-brand-switch").click();
const menu = page.locator(".app-dropdown");
await expect(menu.getByRole("button", { name: "Ателье Норд" })).toHaveClass(/is-checked/);
await menu.getByRole("button", { name: "Вторая организация" }).click();
await expect(page).toHaveURL(new RegExp(`/organizations/${SECOND_ORGANIZATION_PUBLIC_ID}/`));
await expect(page.locator(".hub-brand-switch span")).toHaveText("Вторая организация");
await expect(managerNav(page)).toHaveCount(MANAGER_NAV.length);
});
test("интерфейс работает на минимальной поддерживаемой ширине 1024px", async ({ page }) => {
await page.setViewportSize({ width: 1024, height: 768 });
await mockInstance(page);