Compare commits

..
12 Commits
Author SHA1 Message Date
AndreyandClaude Fable 5.1 1da84c9734 🔖 release: 1.6.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:15:14 +03:00
AndreyandClaude Fable 5.1 82805f7b1a ✨ feat(identity): организации из интерфейса — создание, выбор после входа, /join под ролью app
Кнопка «Добавить организацию» внизу переключателя у логотипа (A1) ведёт на
страницу /organizations/new: логотип, название, часовой пояс, валюта, язык.
Создавший становится владельцем и сразу переключается в новую организацию.
Право — у администратора установки и у владельца или администратора любой
организации; сервер проверяет то же (POST /api/v1/organizations/).

После входа учётная запись с несколькими организациями выбирает, с какой
начать: экран в рамке входа, строки «логотип · название · роль». Прямая
ссылка на организацию экран минует.

tenancy/0035: роль app вставляет организацию только в контексте заранее
выделенного id (как мастер первого запуска) вместо политики «только первая»;
security-barrier каталог invitation_directory — ссылка /join открывается без
контекста, и под ролью app приглашение раньше не находилось вовсе. Тем же
путём язык организации в профиле: членства читаются через каталог входа.

Тесты: создание организации по API, RLS под реальной ролью app (вставка
только в своём контексте, поиск приглашения по токену), e2e переключателя,
страницы создания и экрана выбора. UpdateState исключён из проверки покрытия
демо-набором — одна строка на установку.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:14:24 +03:00
AndreyandClaude Opus 5 eca0e52667 ♻️ refactor(ui): один селект и один скин полей на всё приложение
Карточка агента рисовала свои выпадающие селекты: «Группа» и «Провайдер»
жили на собственной вёрстке и своём CSS, а рядом, в модалке создания
агента, стоял общий SelectField. Два стандарта одного элемента успели
разойтись в высоте, подложке, размере текста, подписи и фокусе.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 03:59:10 +03:00
AndreyandClaude Fable 5.1 54c35e281a 🔖 release: 1.5.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:43:11 +03:00
AndreyandClaude Fable 5.1 5396dda8ad ✨ feat(updates): обновление установки из интерфейса
Установка раз в шесть часов и по кнопке проверяет страницу релизов
GitHub. Администратор установки видит баннер о новой версии и ставит
её одной кнопкой; карточка «Обновления» — в «Настройки → Платформа».

Установку выполняет отдельный сервис updater с Docker-сокетом:
backend-app общается с ним только файлами в томе chatballs-updates
(heartbeat, request.json, status.json). Updater принимает лишь релизы
своего репозитория с образами по digest и применяет compose.yaml в
одноразовом контейнере-помощнике, после чего кладёт файл в каталог
установки. Запрос установки попадает в аудит.

Релизный workflow собирает образ updater и пришпиливает его в
compose.yaml; версия бэкенда зашивается в образ (CHATBALLS_VERSION).
Миграции updates.0001 и tenancy.0034 (гранты на таблицу состояния).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:43:11 +03:00
AndreyandClaude Fable 5.1 29164db094 🔖 release: 1.4.0
SVG-логотип организации; воркер больше не засоряет журнал сбоями опроса
подключений и не опрашивает демо-подключения.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:55 +03:00
AndreyandClaude Fable 5.1 c5a53ccec5 ✨ feat(identity): SVG в качестве логотипа организации
К PNG, JPEG и WebP добавлен SVG. Файл проверяется при загрузке и
отклоняется целиком, если содержит скрипты, обработчики событий,
foreignObject, внешние ссылки в href или url() внутри стилей, DOCTYPE и
сущности — переписывать чужую графику молча хуже, чем попросить другой
файл. При отдаче логотип получает Content-Security-Policy с sandbox и
nosniff как второй рубеж: адрес можно открыть и напрямую.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 049217505a 🔇 fix(worker): пауза после сбоя опроса подключения и тишина в журнале
Подключение с ненастоящим токеном или недоступным сервером писало
предупреждение на каждом цикле опроса, каждые три секунды, на каждое
подключение. Теперь сбой транспорта — исключение PollFailed, а слой пауз
(transports.backoff) пропускает подключение с удвоением паузы от шести
секунд до четверти часа и пишет в журнал только смену состояния: первый
сбой, выход на максимальную паузу и восстановление. Курсор при сбое не
двигается. Демо-подключения из демо-набора помечаются demoSeed и не
опрашиваются вовсе: их токены ненастоящие.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 132c684630 🔖 release: 1.3.1
Исправления после установки на чистый хост: «Ошибка загрузки» сразу после
мастера первого запуска (кэш адреса установки в соседних процессах) и
пустое состояние «Базы знаний», прижатое к левому краю.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 bfe90e9abd 💄 fix(knowledge): пустое состояние библиотеки снова по центру
Коммит 776bc895 при переносе библиотеки на серверную пагинацию удалил
правило контейнера .knowledge-empty, оставив только вложенный блок шириной
480px — он прижимался к левому краю рабочей области. Правило возвращено
как было: flex, по центру, отступ 40px (кадр KB1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 5b49904d6a 🐛 fix(identity): адрес установки виден соседним процессам сразу, а не через 10 секунд
Кэш адреса установки живёт в каждом процессе gunicorn по 10 секунд. Мастер
первого запуска и смена адреса в «Настройках» сбрасывали его только в том
процессе, где выполнялись: соседний отвечал «Invalid host» на первый же
запрос после мастера, и приложение показывало «Ошибка загрузки» до
обновления страницы. Промах по хосту теперь перечитывает строку настроек,
но не чаще раза в секунду на процесс, чтобы поток запросов с чужим Host не
превращался в поток запросов к базе.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 cd50cce5a8 ⬆️ ci(actions): версии actions под Node.js 24
GitHub снимает Node.js 20 с раннеров и уже принудительно запускает старые
версии actions на Node.js 24, о чём предупреждает в каждом прогоне. Все семь
actions подняты до мажорных версий, чей action.yml объявляет node24:
checkout v7, setup-python v7, upload-artifact v7, build-push-action v7,
login-action v4, setup-buildx-action v4, action-gh-release v3. Входы и выходы
шагов не менялись.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 18:55:51 +03:00
94 changed files with 3276 additions and 266 deletions

No files matched your search

+4 -4
View File
@@ -23,9 +23,9 @@ jobs:
name: ruff
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-python@v5
- uses: actions/setup-python@v7
with:
python-version: "3.12"
@@ -46,9 +46,9 @@ jobs:
name: deployment CLI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-python@v5
- uses: actions/setup-python@v7
with:
python-version: "3.12"
+34 -10
View File
@@ -39,12 +39,13 @@ env:
GATEWAY_BASE_IMAGE: caddy:2.8.4
REDIS_IMAGE: redis:7-alpine
COTURN_IMAGE: coturn/coturn:4.6
UPDATER_BASE_IMAGE: docker:27-cli
jobs:
images:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Версия релиза
id: version
@@ -57,9 +58,9 @@ jobs:
echo "value=$version" >> "$GITHUB_OUTPUT"
echo "repo=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v3
- uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -78,13 +79,16 @@ jobs:
echo "gateway=${GATEWAY_BASE_IMAGE}@$(digest_of "$GATEWAY_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
echo "redis=${REDIS_IMAGE}@$(digest_of "$REDIS_IMAGE")" >> "$GITHUB_OUTPUT"
echo "coturn=${COTURN_IMAGE}@$(digest_of "$COTURN_IMAGE")" >> "$GITHUB_OUTPUT"
echo "updater=${UPDATER_BASE_IMAGE}@$(digest_of "$UPDATER_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
- name: Backend
id: backend
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/apps/backend/Dockerfile.production
build-args: |
CHATBALLS_VERSION=${{ steps.version.outputs.value }}
push: true
tags: |
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/backend:${{ steps.version.outputs.value }}
@@ -94,7 +98,7 @@ jobs:
- name: Frontend
id: frontend
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/deploy/docker/frontend.Dockerfile
@@ -109,7 +113,7 @@ jobs:
# а не монтируются с хоста. Ради этого установка и стала одним файлом.
- name: Gateway
id: gateway
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/deploy/docker/gateway.Dockerfile
@@ -124,7 +128,7 @@ jobs:
- name: Postgres
id: postgres
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/deploy/docker/postgres.Dockerfile
@@ -137,6 +141,23 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
# Сервис обновления по кнопке (ADR-CHATBALLS-0049): docker CLI с compose
# и два сценария внутри; единственный, кому монтируется docker.sock.
- name: Updater
id: updater
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/deploy/docker/updater.Dockerfile
build-args: |
CHATBALLS_UPDATER_BASE_IMAGE=${{ steps.bases.outputs.updater }}
push: true
tags: |
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:${{ steps.version.outputs.value }}
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Релизный compose.yaml и release.env
id: artifacts
run: |
@@ -148,6 +169,7 @@ jobs:
frontend="$prefix/frontend:$version@${{ steps.frontend.outputs.digest }}"
gateway="$prefix/gateway:$version@${{ steps.gateway.outputs.digest }}"
postgres="$prefix/postgres:$version@${{ steps.postgres.outputs.digest }}"
updater="$prefix/updater:$version@${{ steps.updater.outputs.digest }}"
redis="${{ steps.bases.outputs.redis }}"
coturn="${{ steps.bases.outputs.coturn }}"
@@ -160,7 +182,8 @@ jobs:
--pin "CHATBALLS_GATEWAY_IMAGE=$gateway" \
--pin "CHATBALLS_POSTGRES_IMAGE=$postgres" \
--pin "CHATBALLS_REDIS_IMAGE=$redis" \
--pin "CHATBALLS_COTURN_IMAGE=$coturn"
--pin "CHATBALLS_COTURN_IMAGE=$coturn" \
--pin "CHATBALLS_UPDATER_IMAGE=$updater"
{
echo "# release.env — digest-пины релиза $version для \`chatballs deploy\`."
@@ -172,6 +195,7 @@ jobs:
echo "CHATBALLS_POSTGRES_IMAGE=$postgres"
echo "CHATBALLS_REDIS_IMAGE=$redis"
echo "CHATBALLS_COTURN_IMAGE=$coturn"
echo "CHATBALLS_UPDATER_IMAGE=$updater"
} > dist/release.env
# Файл, который скачает человек, обязан быть валидным сам по себе —
@@ -180,7 +204,7 @@ jobs:
cat dist/compose.yaml
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: release-compose
path: |
@@ -189,7 +213,7 @@ jobs:
- name: Приложить к релизу
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: |
dist/compose.yaml
+3 -1
View File
@@ -132,7 +132,9 @@ By default files are stored in a Docker volume. In **Settings → Storage** the
### Updating
Download the new release's `compose.yaml` over the old one and restart:
When a new release is out, the installation administrator sees a banner in the interface and updates with one button; the same lives in **Settings → Platform → Updates**. The installation updates itself on the server: it downloads the release `compose.yaml`, pulls the images and restarts the services, with about a minute of downtime.
Manually, from the server console: download the new release's `compose.yaml` over the old one and restart:
```bash
docker compose pull && docker compose up -d --wait
+3 -1
View File
@@ -132,7 +132,9 @@ Relay слушает выделенный IP, чтобы порт 443 не ко
### Обновление
Скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
Когда выходит новый релиз, администратор установки видит баннер в интерфейсе и обновляется одной кнопкой; то же есть в **Настройки → Платформа → Обновления**. Установка идёт на сервере сама: скачивается `compose.yaml` релиза, загружаются образы, сервисы перезапускаются, приложение недоступно около минуты.
Вручную, из консоли сервера: скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
```bash
docker compose pull && docker compose up -d --wait
+1 -1
View File
@@ -1 +1 @@
1.3.0
1.6.0
+5
View File
@@ -6,6 +6,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
# Версия релиза попадает в образ при сборке: по ней приложение решает,
# есть ли обновление. Сборка из исходников остаётся «dev».
ARG CHATBALLS_VERSION=dev
ENV CHATBALLS_VERSION=${CHATBALLS_VERSION}
RUN addgroup --system hub && adduser --system --ingroup hub hub
COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt
@@ -22,6 +22,8 @@ def poll_all_messengers(context) -> int:
channel__is_active=True,
).exclude(secret="")
if integration.config.get("purpose") != "notifications"
# Демо-подключения из демо-набора: токены ненастоящие, опрашивать нечего.
and not integration.config.get("demoSeed")
]
total = 0
for integration in integrations:
@@ -15,6 +15,7 @@ from chatballs.conversations.models import Contact, Conversation, MessageAuthor
from chatballs.conversations.selectors import conversation_messages
from chatballs.conversations.serializers import conversation_payload, message_payload
from chatballs.conversations.transports import email as email_transport
from chatballs.conversations.transports.errors import PollFailed
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
@@ -359,11 +360,11 @@ class EmailPollTests(TestCase):
with mock.patch.object(email_transport, "_imap_connect", side_effect=OSError("refused")):
messages, marker = email_transport.poll_updates(integration)
with self.assertRaises(PollFailed):
email_transport.poll_updates(integration)
self.assertEqual(messages, [])
self.assertEqual(marker, "7:99")
# Курсор не сдвинулся: следующий удачный опрос продолжит с того же места.
self.assertEqual(integration.poll_marker, "7:99")
@@ -0,0 +1,83 @@
"""Сбой опроса подключения: пауза с удвоением и тишина в журнале.
Раньше подключение с ненастоящим токеном писало предупреждение каждые три
секунды. Теперь после сбоя оно пропускается с растущей паузой, журнал видит
первый сбой, выход на максимальную паузу и восстановление, а курсор при
сбое не двигается. Демо-подключения не опрашиваются вовсе.
"""
from __future__ import annotations
from unittest import mock
from django.test import TestCase
from chatballs.channels.models import Channel
from chatballs.conversations import poller
from chatballs.conversations.transports import backoff, poll
from chatballs.conversations.transports.errors import PollFailed
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
class PollBackoffTests(TestCase):
def setUp(self) -> None:
backoff.reset()
self.addCleanup(backoff.reset)
self.organization = Organization.objects.create(name="Poll", slug="poll-org")
with tenant_atomic(self.organization.id):
self.channel = Channel.objects.create(organization=self.organization, name="Main", code="main")
self.integration = Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.TELEGRAM,
name="Bot",
secret="0000:not-a-token",
channel=self.channel,
poll_marker="41",
)
def _fail(self, *_args, **_kwargs):
raise PollFailed("HTTP Error 401: Unauthorized")
def test_failure_is_logged_once_and_then_skipped(self) -> None:
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
with self.assertLogs("chatballs.conversations.transports.backoff", level="WARNING") as logs:
self.assertEqual(poll(self.integration), ([], "41"))
self.assertEqual(len(logs.output), 1)
self.assertIn("401", logs.output[0])
# Пока пауза не вышла, транспорт не вызывается и журнал молчит.
with mock.patch.object(backoff, "_now", return_value=backoff._now()):
with self.assertNoLogs("chatballs.conversations.transports.backoff", level="WARNING"):
self.assertEqual(poll(self.integration), ([], "41"))
self.assertTrue(backoff.should_skip(self.integration.id))
def test_delay_doubles_up_to_the_cap_and_recovery_is_logged(self) -> None:
clock = [1000.0]
with mock.patch.object(backoff, "_now", side_effect=lambda: clock[0]):
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
delays = []
for _ in range(10):
poll(self.integration)
state = backoff._failures[self.integration.id]
delays.append(state.delay)
clock[0] = state.next_attempt_at # ждём ровно до следующей попытки
self.assertEqual(delays[:3], [6.0, 12.0, 24.0])
self.assertEqual(delays[-1], backoff.MAX_DELAY_SECONDS)
self.assertTrue(all(delay <= backoff.MAX_DELAY_SECONDS for delay in delays))
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: lambda _i: ([], "42")}):
with self.assertLogs("chatballs.conversations.transports.backoff", level="INFO") as logs:
self.assertEqual(poll(self.integration), ([], "42"))
self.assertIn("recovered", logs.output[0])
self.assertNotIn(self.integration.id, backoff._failures)
def test_demo_seed_connections_are_not_polled(self) -> None:
with tenant_atomic(self.organization.id):
Integration.objects.filter(pk=self.integration.pk).update(config={"demoSeed": True})
context = TenantContext.for_resource(self.organization)
with mock.patch("chatballs.conversations.poller.transports.poll") as polled:
poller.poll_all_messengers(context)
polled.assert_not_called()
@@ -1,6 +1,8 @@
from chatballs.conversations.transports import backoff
from chatballs.conversations.transports import email as _email
from chatballs.conversations.transports import max as _max
from chatballs.conversations.transports import telegram as _telegram
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import t
from chatballs.integrations.models import IntegrationProvider
@@ -51,7 +53,22 @@ SUPPORTED_PROVIDERS = tuple(_POLL.keys())
def poll(integration):
return _POLL[integration.provider](integration)
"""Опрос подключения с паузой после сбоя (transports.backoff).
Сбой транспорта не роняет цикл и не пишется в журнал на каждой попытке:
подключение пропускается с растущей паузой, а журнал видит только смену
состояния. Курсор при сбое не двигается.
"""
if backoff.should_skip(integration.id):
return [], integration.poll_marker
try:
result = _POLL[integration.provider](integration)
except PollFailed as error:
backoff.record_failure(integration, error)
return [], integration.poll_marker
backoff.record_success(integration)
return result
def send_reply(integration, *, chat_id: str, user_id: str, text: str) -> bool:
@@ -0,0 +1,76 @@
"""Пауза между попытками опроса подключения после сбоя.
Воркер опрашивает мессенджеры каждые три секунды. Подключение с отозванным
токеном или недоступным сервером отвечало ошибкой на каждый цикл и писало
её в журнал двадцать раз в минуту — журнал переставал быть читаемым, а
чужой сервер получал бессмысленный поток запросов. Теперь после сбоя
подключение пропускается с растущей паузой, а в журнал попадают только
изменения состояния: первый сбой, выход на максимальную паузу и
восстановление.
Состояние живёт в памяти процесса: воркер один, а после перезапуска первая
попытка всё равно нужна.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# Первая пауза — два цикла опроса, дальше удвоение до четверти часа.
FIRST_DELAY_SECONDS = 6.0
MAX_DELAY_SECONDS = 900.0
@dataclass
class _Failure:
failures: int
next_attempt_at: float
delay: float
_failures: dict[int, _Failure] = {}
def _now() -> float:
return time.monotonic()
def should_skip(integration_id: int) -> bool:
state = _failures.get(integration_id)
return state is not None and _now() < state.next_attempt_at
def record_failure(integration, error: object) -> None:
previous = _failures.get(integration.id)
failures = (previous.failures if previous else 0) + 1
delay = min(FIRST_DELAY_SECONDS * 2 ** (failures - 1), MAX_DELAY_SECONDS)
_failures[integration.id] = _Failure(failures=failures, next_attempt_at=_now() + delay, delay=delay)
if failures == 1:
logger.warning(
"%s poll failed for integration %s: %s (next attempt in %.0fs)",
integration.provider, integration.id, error, delay,
)
elif delay >= MAX_DELAY_SECONDS and (previous is None or previous.delay < MAX_DELAY_SECONDS):
logger.warning(
"%s poll keeps failing for integration %s: %s (retrying every %.0f min)",
integration.provider, integration.id, error, MAX_DELAY_SECONDS / 60,
)
def record_success(integration) -> None:
state = _failures.pop(integration.id, None)
if state is not None:
logger.info(
"%s poll recovered for integration %s after %s failure(s)",
integration.provider, integration.id, state.failures,
)
def reset() -> None:
"""Для тестов: забыть все сбои."""
_failures.clear()
@@ -26,6 +26,7 @@ from chatballs.conversations.transports.base import (
guess_content_type,
safe_filename,
)
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
logger = logging.getLogger(__name__)
@@ -135,8 +136,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
try:
client = _imap_connect(integration)
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
try:
client.select("INBOX", readonly=True)
validity = _status_value(client, "UIDVALIDITY")
@@ -162,8 +162,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
new_marker = f"{validity}:{uids[-1]}" if uids else integration.poll_marker
return messages, new_marker
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
finally:
try:
client.logout()
@@ -0,0 +1,6 @@
class PollFailed(Exception):
"""Опрос подключения не удался: сеть, авторизация, ответ не разобран.
Транспорт не пишет об этом в журнал сам — решение, когда повторить и что
записать, принимает слой пауз между попытками (``transports.backoff``).
"""
@@ -27,6 +27,7 @@ from chatballs.conversations.transports.base import (
request_json_multipart,
safe_filename,
)
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
from chatballs.integrations.checks import DEFAULT_MAX_BASE_URL
from chatballs.integrations.outbound import host_of
@@ -242,8 +243,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
try:
data = request_json(url, headers={"Authorization": token, "Content-Type": "application/json"}, proxy_url=_proxy(integration))
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
logger.warning("MAX poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
updates = data.get("updates") or []
messages = [m for m in (_normalize(u) for u in updates) if m is not None]
new_marker = data.get("marker")
@@ -23,6 +23,7 @@ from chatballs.conversations.transports.base import (
request_json_multipart,
safe_filename,
)
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
from chatballs.integrations.checks import DEFAULT_TELEGRAM_BASE_URL
from chatballs.integrations.outbound import host_of
@@ -124,8 +125,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
try:
data = request_json(url, proxy_url=_proxy(integration))
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
logger.warning("Telegram poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
if not data.get("ok"):
return [], integration.poll_marker
updates = data.get("result") or []
@@ -15,6 +15,7 @@ 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
from chatballs.updates.services import check_for_updates
logger = logging.getLogger(__name__)
@@ -92,6 +93,12 @@ class Command(BaseCommand):
logger.exception("Call sweep cycle failed")
if now - last_maintenance >= MAINTENANCE_INTERVAL:
last_maintenance = now
# Канал релизов спрашивается не чаще раза в несколько часов:
# интервал держит сама проверка по времени последнего ответа.
try:
check_for_updates()
except Exception: # pragma: no cover
logger.exception("Update check cycle failed")
try:
for context in self._tenant_contexts():
with tenant_atomic(context):
+8 -1
View File
@@ -213,6 +213,7 @@ MESSAGES: dict[str, object] = {
"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_updates_install_requested": "Installation update started",
"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",
@@ -246,6 +247,7 @@ MESSAGES: dict[str, object] = {
"audit.action_integrations_integration_created": "Integration added",
"audit.action_integrations_integration_deleted": "Integration deleted",
"audit.action_integrations_integration_updated": "Integration changed",
"audit.action_organization_created": "Organization created from the interface",
"audit.action_organization_owner_activated": "Organization owner activated",
"audit.action_organization_owner_invitation_requested": "Owner invitation sent",
"audit.action_organization_provisioned": "Organization created",
@@ -331,8 +333,12 @@ MESSAGES: dict[str, object] = {
"identity.invalid_credentials": "Invalid credentials",
"identity.invalid_totp_code": "Invalid TOTP code",
"identity.invitation_email_mismatch": "Invitation email does not match the account",
"identity.organization_create_forbidden": "Only the installation administrator and organization owners or administrators can create organizations",
"identity.invitation_invalid": "Invitation is invalid or has expired",
"identity.invitation_account_exists": "An account with this address already exists. Sign in with it",
"updates.nothing_to_install": "The latest version is already installed",
"updates.updater_offline": "The update service is not running, so updating from the interface is unavailable",
"updates.install_in_progress": "An update is already in progress",
"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",
@@ -355,7 +361,8 @@ MESSAGES: dict[str, object] = {
"api.expected_record_id": "{name}: a record identifier is expected",
"settings.storage_unavailable": "The storage is unavailable: {error}",
"admin.actor_system": "System",
"admin.image_formats": "PNG, JPEG and WebP are supported",
"admin.image_formats": "PNG, JPEG, WebP and SVG are supported",
"admin.svg_logo_unsafe": "SVG must not contain scripts, event handlers or external links",
"admin.logo_too_large": "The logo must not exceed 2 MB",
"calls.calls_off_entry_point": "Calls are switched off for this entry point",
"calls.failure_code_required": "FAILED needs a normalized failure_code",
+8 -1
View File
@@ -217,6 +217,7 @@ MESSAGES: dict[str, object] = {
"audit.action_identity_employee_invited": "Отправлено приглашение сотруднику",
"audit.action_identity_invitation_accepted": "Принято приглашение в организацию",
"audit.action_identity_invitation_revoked": "Отозвано приглашение сотрудника",
"audit.action_updates_install_requested": "Запущено обновление установки",
"audit.action_identity_employee_groups_changed": "Изменены группы сотрудника",
"audit.action_identity_employee_password_reset": "Сброшен пароль сотрудника",
"audit.action_identity_employee_privileged_action_denied": "Отказано в привилегированном действии",
@@ -250,6 +251,7 @@ MESSAGES: dict[str, object] = {
"audit.action_integrations_integration_created": "Добавлена интеграция",
"audit.action_integrations_integration_deleted": "Удалена интеграция",
"audit.action_integrations_integration_updated": "Изменена интеграция",
"audit.action_organization_created": "Создана организация из интерфейса",
"audit.action_organization_owner_activated": "Активирован владелец организации",
"audit.action_organization_owner_invitation_requested": "Отправлено приглашение владельцу",
"audit.action_organization_provisioned": "Создана организация",
@@ -335,8 +337,12 @@ MESSAGES: dict[str, object] = {
"identity.invalid_credentials": "Неверный email или пароль",
"identity.invalid_totp_code": "Неверный код",
"identity.invitation_email_mismatch": "Приглашение выписано на другой адрес",
"identity.organization_create_forbidden": "Создавать организации могут администратор установки и владельцы или администраторы организаций",
"identity.invitation_invalid": "Приглашение недействительно или истекло",
"identity.invitation_account_exists": "Учётная запись с этим адресом уже есть — войдите под ней",
"updates.nothing_to_install": "Установлена последняя версия",
"updates.updater_offline": "Сервис обновления не запущен — обновление из интерфейса недоступно",
"updates.install_in_progress": "Обновление уже идёт",
"identity.role_conflict": "У пользователя уже другая роль в этой организации",
"identity.token_required": "Нужен токен",
"identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново",
@@ -359,7 +365,8 @@ MESSAGES: dict[str, object] = {
"api.expected_record_id": "{name}: ожидается идентификатор записи",
"settings.storage_unavailable": "Хранилище недоступно: {error}",
"admin.actor_system": "Система",
"admin.image_formats": "Поддерживаются PNG, JPEG и WebP",
"admin.image_formats": "Поддерживаются PNG, JPEG, WebP и SVG",
"admin.svg_logo_unsafe": "В SVG не должно быть скриптов, обработчиков событий и внешних ссылок",
"admin.logo_too_large": "Размер логотипа не должен превышать 2 МБ",
"calls.calls_off_entry_point": "Звонки отключены для этой точки входа",
"calls.failure_code_required": "Для FAILED требуется нормализованный failure_code",
@@ -11,6 +11,7 @@ from django.db import transaction
from chatballs.i18n import t
from chatballs.i18n.languages import normalize_language
from chatballs.identity.logo_svg import SVG_CONTENT_TYPE, looks_like_svg, svg_is_safe
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.storage import adjust_storage_usage
@@ -34,7 +35,10 @@ class OrganizationSettingsInput:
language: str = ""
def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
def validate_organization_settings(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
"""Имя, часовой пояс, валюта и язык организации — одни правила для
«Настроек» и для страницы создания организации."""
name = data.name.strip()
timezone = data.timezone.strip()
currency = data.currency.strip().upper()
@@ -66,7 +70,7 @@ def update_organization_settings(
context: TenantContext,
data: OrganizationSettingsInput,
) -> Organization:
clean = _validate_input(data)
clean = validate_organization_settings(data)
organization = Organization.objects.select_for_update().get(
pk=context.organization_id
)
@@ -85,6 +89,8 @@ def _image_type(data: bytes) -> tuple[str, str] | None:
return "image/jpeg", ".jpg"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp", ".webp"
if looks_like_svg(data):
return SVG_CONTENT_TYPE, ".svg"
return None
@@ -102,6 +108,10 @@ def replace_organization_logo(
detected = _image_type(data)
if detected is None:
raise ValidationError({"file": t("admin.image_formats")})
if detected[0] == SVG_CONTENT_TYPE and not svg_is_safe(data):
# Скрипты, внешние ссылки и обработчики событий в логотипе не нужны:
# файл отклоняется целиком, а не переписывается молча.
raise ValidationError({"file": t("admin.svg_logo_unsafe")})
content_type, suffix = detected
organization = Organization.objects.select_for_update().get(
pk=context.organization_id
@@ -107,11 +107,18 @@ class OrganizationLogoView(APIView):
organization = request.tenant_context.organization
if not organization.logo:
return Response({"detail": t("admin.logo_not_uploaded")}, status=404)
return FileResponse(
response = FileResponse(
organization.logo.open("rb"),
content_type=organization.logo_content_type or "application/octet-stream",
filename="organization-logo",
)
# Логотип показывается через <img>, но адрес можно открыть и напрямую.
# SVG проверен при загрузке; заголовки — второй рубеж: в контексте
# документа ему нельзя ни исполнять скрипты, ни ходить наружу, а
# браузеру нельзя угадывать тип.
response["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"
response["X-Content-Type-Options"] = "nosniff"
return response
def post(self, request: Request) -> Response:
upload = request.FILES.get("file")
@@ -81,6 +81,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
"identity.employee_invited": "audit.action_identity_employee_invited",
"identity.invitation_accepted": "audit.action_identity_invitation_accepted",
"identity.invitation_revoked": "audit.action_identity_invitation_revoked",
"updates.install_requested": "audit.action_updates_install_requested",
"identity.employee_updated": "audit.action_identity_employee_updated",
"identity.employee_blocked": "audit.action_identity_employee_blocked",
"identity.employee_unblocked": "audit.action_identity_employee_unblocked",
@@ -110,6 +111,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
"administration.instance_updated": "audit.action_administration_instance_updated",
# --- Организация ---
"organization.provisioned": "audit.action_organization_provisioned",
"organization.created": "audit.action_organization_created",
"organization.owner_activated": "audit.action_organization_owner_activated",
"organization.owner_invitation_requested": "audit.action_organization_owner_invitation_requested",
# --- Интеграции и каналы ---
@@ -17,7 +17,8 @@ from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
from chatballs.identity.instance_settings import default_language
from chatballs.identity.models import HumanUser, OrganizationMembership
from chatballs.identity.sessions import list_user_sessions
from chatballs.tenancy.ingress import user_requires_totp
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import membership_routes_for_user, user_requires_totp
class ProfileUpdateView(APIView):
@@ -289,10 +290,20 @@ def _request_organization_language(request: Request) -> str:
context = getattr(request, "tenant_context", None)
if context is not None:
return context.organization.language or ""
membership = (
OrganizationMembership.objects.select_related("organization")
.filter(user=request.user, blocked_at__isnull=True)
.order_by("created_at", "id")
.first()
)
return membership.organization.language if membership is not None else ""
# Членства роли app без контекста не видны: сначала каталог входа, затем
# каждое членство читается в контексте своей организации — как в
# identity.auth.common._user_payload.
oldest: tuple[object, int, str] | None = None
for route in membership_routes_for_user(request.user.id):
with tenant_atomic(route.organization_id):
membership = (
OrganizationMembership.objects.select_related("organization")
.filter(id=route.resource_id, user=request.user, blocked_at__isnull=True)
.first()
)
if membership is None:
continue
key = (membership.created_at, membership.id, membership.organization.language or "")
if oldest is None or key[:2] < oldest[:2]:
oldest = key
return oldest[2] if oldest is not None else ""
@@ -115,7 +115,8 @@ def _ensure_connection(context: TenantContext, refs: DemoRefs, item: dict, curre
provider=item["provider"],
name=item["name"],
secret=item.get("secret", ""),
config=item.get("config", {}),
# Токены демо-подключений ненастоящие: воркер их не опрашивает.
config={**item.get("config", {}), "demoSeed": True},
),
)
Integration.objects.filter(pk=integration.pk).update(
@@ -72,8 +72,12 @@ class InstanceSettings(models.Model):
_CACHE_TTL_SECONDS = 10.0
# Перечитать кэш при промахе по хосту можно не чаще раза в секунду на процесс:
# иначе поток запросов с чужим Host превращался бы в поток запросов к базе.
_MISS_REFRESH_SECONDS = 1.0
_lock = threading.Lock()
_cached: tuple[float, tuple[str, str]] | None = None
_last_miss_refresh = 0.0
def invalidate_cache() -> None:
@@ -112,6 +116,31 @@ def accepted_hosts() -> tuple[str, ...]:
return tuple(host for host in _hosts() if host)
def host_is_accepted(host: str) -> bool:
"""Свой ли это адрес — с перечитыванием кэша при промахе.
Кэш живёт в каждом процессе gunicorn отдельно. Мастер первого запуска или
смена адреса в «Настройках» сбрасывают его только там, где выполнялись;
соседний процесс до 10 секунд отвечал бы «Invalid host» на адрес, который
установка уже считает своим. Поэтому промах — повод перечитать строку, но
не чаще раза в секунду.
"""
global _last_miss_refresh
if not host:
return False
known = {normalize_domain(item) for item in accepted_hosts()}
if host in known:
return True
now = time.monotonic()
with _lock:
if now - _last_miss_refresh < _MISS_REFRESH_SECONDS:
return False
_last_miss_refresh = now
invalidate_cache()
return host in {normalize_domain(item) for item in accepted_hosts()}
def default_language() -> str:
"""Язык установки: экраны до входа и умолчание для организаций.
@@ -10,6 +10,7 @@ from django.urls import path
from chatballs.identity import instance_views
from chatballs.tenancy import storage_views
from chatballs.updates import views as update_views
urlpatterns = [
path("settings/", instance_views.InstanceAddressView.as_view(), name="instance-settings"),
@@ -25,4 +26,8 @@ urlpatterns = [
storage_views.StorageMigrateView.as_view(),
name="instance-storage-migrate",
),
# Обновления установки: состояние, проверка канала, установка по кнопке.
path("update/", update_views.UpdateStateView.as_view(), name="instance-update"),
path("update/check/", update_views.UpdateCheckView.as_view(), name="instance-update-check"),
path("update/install/", update_views.UpdateInstallView.as_view(), name="instance-update-install"),
]
@@ -25,6 +25,7 @@ from chatballs.identity.models import (
)
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import invitation_route
# Приглашение существующего пользователя в организацию: письмо отправляет
# воркер по этому событию (identity.event_handlers).
@@ -204,14 +205,37 @@ def register_and_accept(*, token: str, full_name: str, password: str) -> Accepte
def pending_invitation_for_token(token: str) -> OrganizationInvitation | None:
return _invitation_for_token(token, accepted=False)
def _invitation_for_token(token: str, *, accepted: bool) -> OrganizationInvitation | None:
"""Приглашение по токену из письма — без tenant-контекста на входе.
Ссылка /join приходит до входа в организацию, а таблица приглашений и
строка организации роли app без контекста не видны (tenancy/0003, 0033).
Организацию находит security-barrier каталог по хэшу токена (tenancy/0035),
и приглашение читается уже в её контексте — вместе с организацией, чтобы
вызывающий код мог обращаться к ней и после выхода из контекста.
"""
if not token:
return None
return OrganizationInvitation.objects.filter(
token_hash=_token_hash(token),
accepted_at__isnull=True,
token_hash = _token_hash(token)
route = invitation_route(token_hash)
if route is None:
return None
query = OrganizationInvitation.objects.select_related("organization").filter(
id=route.resource_id,
organization_id=route.organization_id,
token_hash=token_hash,
revoked_at__isnull=True,
expires_at__gt=timezone.now(),
).first()
)
if accepted:
query = query.filter(accepted_at__isnull=False)
else:
query = query.filter(accepted_at__isnull=True, expires_at__gt=timezone.now())
with tenant_atomic(route.organization_id):
return query.first()
@transaction.atomic
@@ -314,16 +338,13 @@ def _already_accepted_for(
) -> AcceptedInvitation | None:
"""Idempotent re-accept: if this token was already accepted by the same user,
return the existing result instead of raising (SPEC-HUB-0021 §11/§15)."""
invitation = OrganizationInvitation.objects.filter(
token_hash=_token_hash(token),
accepted_at__isnull=False,
revoked_at__isnull=True,
).first()
invitation = _invitation_for_token(token, accepted=True)
if invitation is None:
return None
membership = OrganizationMembership.objects.filter(
user=user, organization=invitation.organization
).first()
with tenant_atomic(invitation.organization_id):
membership = OrganizationMembership.objects.filter(
user=user, organization=invitation.organization
).first()
if membership is None:
return None
return AcceptedInvitation(
@@ -0,0 +1,72 @@
"""Проверка SVG-логотипа организации.
SVG — это XML с возможностью исполнять скрипты и тянуть внешние ресурсы.
Логотип отдаётся с адреса самого приложения, поэтому опасный файл принимать
нельзя даже с защитными заголовками при отдаче: файл проверяется при загрузке
и отклоняется целиком, а не «чистится» — переписывать чужую графику молча
хуже, чем попросить другой файл.
Отклоняется: DOCTYPE и сущности, элементы script/foreignObject/iframe/
embed/object/audio/video, атрибуты-обработчики on*, ссылки javascript: и
data:text, внешние адреса в href/xlink:href и в url() внутри стилей.
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
SVG_CONTENT_TYPE = "image/svg+xml"
_FORBIDDEN_TAGS = frozenset({"script", "foreignobject", "iframe", "embed", "object", "audio", "video"})
_HREF_ATTRIBUTES = frozenset({"href", "{http://www.w3.org/1999/xlink}href"})
_DECLARATION = re.compile(rb"<!\s*(DOCTYPE|ENTITY)", re.IGNORECASE)
_EXTERNAL_URL = re.compile(r"url\(\s*['\"]?\s*(?!#|data:image/)", re.IGNORECASE)
def looks_like_svg(data: bytes) -> bool:
head = data.lstrip(b"\xef\xbb\xbf \t\r\n")[:4096].lower()
if head.startswith(b"<svg"):
return True
return head.startswith((b"<?xml", b"<!--")) and b"<svg" in head
def _local(name: str) -> str:
return name.rsplit("}", 1)[-1].lower()
def _dangerous_value(value: str) -> bool:
compact = re.sub(r"\s+", "", value).lower()
return compact.startswith("javascript:") or compact.startswith("data:text") or compact.startswith("vbscript:")
def svg_is_safe(data: bytes) -> bool:
if _DECLARATION.search(data):
return False
try:
root = ET.fromstring(data)
except ET.ParseError:
return False
if _local(root.tag) != "svg":
return False
for element in root.iter():
tag = _local(element.tag) if isinstance(element.tag, str) else ""
if tag in _FORBIDDEN_TAGS:
return False
for name, value in element.attrib.items():
local = _local(name)
if local.startswith("on"):
return False
if _dangerous_value(value):
return False
if name in _HREF_ATTRIBUTES or local == "href":
stripped = value.strip()
if stripped and not (stripped.startswith("#") or stripped.lower().startswith("data:image/")):
return False
if local == "style" and _EXTERNAL_URL.search(value):
return False
if tag == "style" and element.text and (
"@import" in element.text.lower() or _EXTERNAL_URL.search(element.text)
):
return False
return True
@@ -0,0 +1,143 @@
"""Создание организации человеком из интерфейса.
Кнопка «Добавить организацию» в переключателе (дизайн-базлайн v2, A1) ведёт
на страницу с полями организации; тот, кто её заполнил, становится владельцем
новой организации и сразу в неё переключается. Это второй путь появления
организации рядом с платформенным провижинингом (platform.provisioning_service):
там оператор заводит организацию для чужого владельца по e-mail, здесь человек
заводит её себе.
Кто может: администратор установки и любой, у кого есть роль владельца или
администратора хотя бы в одной организации. Сотрудник, работающий только в
чате, чужую установку организациями не засевает.
"""
from __future__ import annotations
from dataclasses import dataclass
from django.db import transaction
from django.utils.text import slugify
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.i18n.audience import customer_language
from chatballs.identity.administration_services import (
OrganizationSettingsInput,
validate_organization_settings,
)
from chatballs.identity.audit import record_audit_event
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
OrganizationStatus,
)
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import membership_routes_for_user
from chatballs.tenancy.lookup import organization_route_by_slug, reserve_organization_id
MANAGER_ROLES = frozenset({EmployeeRole.OWNER, EmployeeRole.ADMIN})
@dataclass(frozen=True, slots=True)
class CreatedOrganization:
organization: Organization
membership: OrganizationMembership
def can_create_organization(user: HumanUser) -> bool:
"""Администратор установки или менеджер (владелец/администратор) где-либо."""
if not user.is_active:
return False
if user.is_instance_admin:
return True
for route in membership_routes_for_user(user.id):
with tenant_atomic(route.organization_id):
role = (
OrganizationMembership.objects.filter(
id=route.resource_id, user=user, blocked_at__isnull=True
)
.values_list("role", flat=True)
.first()
)
if role in MANAGER_ROLES:
return True
return False
def unique_organization_slug(name: str) -> str:
"""Слаг из имени, уникальный среди организаций установки.
Проверка идёт через каталог организаций: роль app без контекста строк
организаций не видит (tenancy/0033).
"""
base = slugify(name)[:40].strip("-") or "organization"
candidate = base
suffix = 2
while organization_route_by_slug(candidate) is not None:
candidate = f"{base}-{suffix}"
suffix += 1
return candidate
def create_organization(
*, data: OrganizationSettingsInput, owner: HumanUser
) -> CreatedOrganization:
"""Создать организацию и сделать человека её владельцем — одной транзакцией.
Порядок тот же, что у мастера первого запуска (identity.setup): id
выделяется заранее, строка вставляется уже в контексте этого id — иначе
роль app не увидит собственную вставку (tenancy/0033, политика 0035).
"""
clean = validate_organization_settings(data)
with transaction.atomic():
organization_id = reserve_organization_id()
with tenant_atomic(organization_id):
organization = Organization(
id=organization_id,
name=clean.name,
slug=unique_organization_slug(clean.name),
status=OrganizationStatus.ACTIVE,
timezone=clean.timezone,
currency=clean.currency,
language=clean.language,
)
organization.save(force_insert=True)
ensure_uncategorized_category(organization)
membership = OrganizationMembership.objects.create(
user=owner,
organization=organization,
role=EmployeeRole.OWNER,
# Должность — текстом на языке организации, как в провижининге.
position_title=t("setup.owner_position", language=customer_language(organization)),
totp_required=False,
)
record_audit_event(
action="organization.created",
actor=owner,
organization=organization,
object_type="Organization",
object_id=str(organization.public_id),
payload={"organizationName": organization.name},
)
enqueue_event(
DomainEvent(
aggregate_type="Organization",
aggregate_id=str(organization.public_id),
event_type="organization.provisioned",
payload={},
tenant_context=TenantContext.for_resource(
organization,
actor_kind=TenantActorKind.SYSTEM,
actor_user=owner,
),
)
)
return CreatedOrganization(organization=organization, membership=membership)
@@ -0,0 +1,12 @@
from django.urls import path
from chatballs.identity import organization_views
urlpatterns = [
path("", organization_views.OrganizationCreateView.as_view(), name="organization-create"),
path(
"options/",
organization_views.OrganizationCreateOptionsView.as_view(),
name="organization-create-options",
),
]
@@ -0,0 +1,76 @@
"""Создание организации из интерфейса: /api/v1/organizations/ без uuid в адресе.
Организации ещё нет, поэтому tenant middleware этот путь не трогает: контекст
открывает сам сервис вокруг вставки. Ответ повторяет форму ответа приглашения
(auth.invitations): обновлённая учётная запись со списком членств и публичный
id организации, в которую интерфейсу переключиться.
"""
from __future__ import annotations
from django.core.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.i18n import t
from chatballs.identity.administration_payloads import (
administration_languages,
administration_timezones,
)
from chatballs.identity.administration_services import OrganizationSettingsInput
from chatballs.identity.auth.common import _user_payload, validation_response
from chatballs.identity.organization_creation import (
can_create_organization,
create_organization,
)
def _forbidden() -> Response:
return Response({"detail": t("identity.organization_create_forbidden")}, status=403)
class OrganizationCreateOptionsView(APIView):
"""Справочники для формы: часовые пояса и языки, как в «Настройках»."""
permission_classes = [IsAuthenticated]
def get(self, request: Request) -> Response:
if not can_create_organization(request.user):
return _forbidden()
return Response(
{
"timezones": administration_timezones(),
"languages": administration_languages(),
"currencies": ["RUB"],
}
)
class OrganizationCreateView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request) -> Response:
if not can_create_organization(request.user):
return _forbidden()
body = request.data if isinstance(request.data, dict) else {}
try:
created = create_organization(
data=OrganizationSettingsInput(
name=str(body.get("name", "")),
timezone=str(body.get("timezone", "") or "Europe/Moscow"),
currency=str(body.get("currency", "") or "RUB"),
language=str(body.get("language", "")),
),
owner=request.user,
)
except ValidationError as error:
return validation_response(error)
return Response(
{
"user": _user_payload(request.user),
"organizationPublicId": str(created.organization.public_id),
},
status=201,
)
@@ -172,16 +172,16 @@ class AdministrationApiTests(TestCase):
"/api/v1/company/administration/logo/",
{
"file": SimpleUploadedFile(
"logo.svg",
b"<svg></svg>",
content_type="image/svg+xml",
"logo.txt",
b"not an image at all",
content_type="text/plain",
)
},
format="multipart",
)
self.assertEqual(response.status_code, 400)
self.assertIn("PNG, JPEG и WebP", response.json()["detail"])
self.assertIn("PNG, JPEG, WebP и SVG", response.json()["detail"])
def test_audit_returns_readable_label_next_to_the_action_code(self) -> None:
record_audit_event(
@@ -12,9 +12,12 @@
from __future__ import annotations
import time
from django.core.exceptions import ValidationError
from django.test import TestCase
from chatballs.identity import instance_settings
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.instance_settings import (
InstanceSettings,
@@ -201,3 +204,51 @@ class InstanceSettingsAccessTests(TestCase):
f"/api/v1/organizations/{self.result.organization.public_id}/company/administration/instance/"
)
self.assertEqual(response.status_code, 404)
class StaleHostCacheTests(TestCase):
"""Адрес, записанный мастером в одном процессе, принимает и соседний.
Кэш адреса живёт в каждом процессе gunicorn по 10 секунд. Соседний процесс
с устаревшим кэшем отвечал «Invalid host» на первый же запрос после
мастера — в интерфейсе это «Ошибка загрузки», исчезавшая после обновления
страницы. Промах по хосту теперь перечитывает кэш.
"""
def setUp(self) -> None:
self.result = bootstrap_owner(email="cache-owner@example.com", password=PASSWORD)
self.client = TenantAPIClient()
self.client.force_authenticate(self.result.owner)
# Строка настроек должна существовать: update() ниже её не создаёт.
InstanceSettings.load()
self.addCleanup(invalidate_cache)
def _stale_cache_with_no_host(self) -> None:
# Соседний процесс: только что прочитал пустой адрес, TTL ещё не вышел.
instance_settings._cached = (time.monotonic(), ("", ""))
instance_settings._last_miss_refresh = 0.0
def test_host_written_by_another_process_is_accepted_at_once(self) -> None:
self._stale_cache_with_no_host()
# Запись мимо save(): invalidate_cache() в этом процессе не вызывается,
# как и в реальности, где мастер отработал в другом воркере.
InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).update(
public_host="crm.example.test"
)
response = self.client.get("/api/v1/auth/session/", HTTP_HOST="crm.example.test")
self.assertEqual(response.status_code, 200, response.content)
def test_unknown_host_does_not_reread_more_than_once_a_second(self) -> None:
InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).update(
public_host="crm.example.test"
)
self._stale_cache_with_no_host()
self.assertEqual(self.client.get("/api/v1/auth/session/", HTTP_HOST="evil.example").status_code, 400)
# Первый промах перечитал кэш и уже знает настоящий адрес.
self.assertEqual(set(accepted_hosts()), {"crm.example.test"})
# Второй промах в ту же секунду базу не трогает: кэш подменён, но не перечитан.
instance_settings._cached = (time.monotonic(), ("", ""))
self.assertEqual(self.client.get("/api/v1/auth/session/", HTTP_HOST="evil.example").status_code, 400)
self.assertEqual(accepted_hosts(), ())
@@ -0,0 +1,77 @@
"""SVG-логотип организации: принимается чистый, отклоняется опасный.
Логотип отдаётся с адреса приложения, поэтому SVG проверяется при загрузке
(скрипты, обработчики, внешние ссылки), а при отдаче получает защитные
заголовки как второй рубеж.
"""
from __future__ import annotations
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.logo_svg import svg_is_safe
from chatballs.testing import TenantAPIClient
CLEAN_SVG = b"""<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64">
<defs><linearGradient id="g"><stop offset="0" stop-color="#1677ff"/><stop offset="1" stop-color="#003eb3"/></linearGradient></defs>
<style>.mark { fill: url(#g); }</style>
<circle class="mark" cx="32" cy="32" r="30"/>
<use xlink:href="#mark"/>
</svg>
"""
class SvgSafetyTests(TestCase):
def test_clean_svg_is_accepted(self) -> None:
self.assertTrue(svg_is_safe(CLEAN_SVG))
def test_dangerous_svg_is_rejected(self) -> None:
samples = {
"script": b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
"handler": b'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect/></svg>',
"javascript href": b'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><a xlink:href="javascript:alert(1)"><rect/></a></svg>',
"external image": b'<svg xmlns="http://www.w3.org/2000/svg"><image href="https://evil.example/t.png"/></svg>',
"foreignObject": b'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div>x</div></foreignObject></svg>',
"external css": b'<svg xmlns="http://www.w3.org/2000/svg"><style>@import url(https://evil.example/a.css);</style></svg>',
"style url": b'<svg xmlns="http://www.w3.org/2000/svg"><rect style="fill:url(http://evil.example/x)"/></svg>',
"doctype entity": b'<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x "y">]><svg xmlns="http://www.w3.org/2000/svg"/>',
"not svg": b'<html><body>hi</body></html>',
"broken xml": b'<svg xmlns="http://www.w3.org/2000/svg"><rect></svg>',
}
for name, sample in samples.items():
with self.subTest(name):
self.assertFalse(svg_is_safe(sample))
class SvgLogoApiTests(TestCase):
def setUp(self) -> None:
result = bootstrap_owner(email="svg-owner@example.com", password="temporary-password")
self.client = TenantAPIClient()
self.client.force_authenticate(result.owner)
def _upload(self, data: bytes):
return self.client.post(
"/api/v1/company/administration/logo/",
{"file": SimpleUploadedFile("logo.svg", data, content_type="image/svg+xml")},
format="multipart",
)
def test_svg_logo_round_trip_with_protective_headers(self) -> None:
uploaded = self._upload(CLEAN_SVG)
self.assertEqual(uploaded.status_code, 200, uploaded.content)
downloaded = self.client.get("/api/v1/company/administration/logo/")
self.assertEqual(downloaded.status_code, 200)
self.assertEqual(downloaded["Content-Type"], "image/svg+xml")
self.assertIn("sandbox", downloaded["Content-Security-Policy"])
self.assertEqual(downloaded["X-Content-Type-Options"], "nosniff")
self.assertEqual(b"".join(downloaded.streaming_content), CLEAN_SVG)
def test_svg_with_script_is_refused_with_a_field_error(self) -> None:
response = self._upload(b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')
self.assertEqual(response.status_code, 400)
self.assertIn("file", response.json()["errors"])
@@ -0,0 +1,122 @@
"""Создание организации из интерфейса: кнопка «Добавить организацию» (A1)."""
from __future__ import annotations
import json
from django.test import TestCase, override_settings
from chatballs.ai.models import KnowledgeCategory
from chatballs.identity.models import (
AuditEvent,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from chatballs.testing import TenantAPIClient
URL = "/api/v1/organizations/"
@override_settings(ROOT_URLCONF="chatballs_backend.urls_app")
class OrganizationCreationTests(TestCase):
def setUp(self) -> None:
self.first = Organization.objects.create(name="Ателье Норд", slug="atelie-nord")
self.owner = HumanUser.objects.create_user(
email="owner@example.test", password="Owner-pass-123!", full_name="Елена"
)
OrganizationMembership.objects.create(
organization=self.first,
user=self.owner,
role=EmployeeRole.OWNER,
position_title="Владелец",
)
self.employee = HumanUser.objects.create_user(
email="employee@example.test", password="Emp-pass-1234!", full_name="Иван"
)
OrganizationMembership.objects.create(
organization=self.first,
user=self.employee,
role=EmployeeRole.EMPLOYEE,
position_title="Оператор",
)
def _post(self, user: HumanUser, **body):
client = TenantAPIClient()
client.force_login(user)
return client.post(
URL,
data=json.dumps({"name": "Вторая компания", "timezone": "Europe/Moscow", **body}),
content_type="application/json",
)
def test_owner_creates_organization_and_becomes_its_owner(self) -> None:
response = self._post(self.owner, language="en")
self.assertEqual(response.status_code, 201, response.content)
payload = response.json()
created = Organization.objects.get(public_id=payload["organizationPublicId"])
self.assertEqual(created.name, "Вторая компания")
self.assertEqual(created.language, "en")
self.assertEqual(created.status, "ACTIVE")
membership = OrganizationMembership.objects.get(organization=created, user=self.owner)
self.assertEqual(membership.role, EmployeeRole.OWNER)
self.assertTrue(KnowledgeCategory.objects.filter(organization=created).exists())
# Список членств в ответе уже содержит новую организацию: интерфейсу
# есть куда переключиться без повторного запроса сессии.
self.assertEqual(
{item["organizationPublicId"] for item in payload["user"]["memberships"]},
{str(self.first.public_id), str(created.public_id)},
)
self.assertTrue(
AuditEvent.objects.filter(
organization=created, action="organization.created", actor=self.owner
).exists()
)
def test_same_name_gets_a_distinct_slug(self) -> None:
first = self._post(self.owner).json()["organizationPublicId"]
second = self._post(self.owner).json()["organizationPublicId"]
slugs = set(Organization.objects.filter(public_id__in=[first, second]).values_list("slug", flat=True))
self.assertEqual(len(slugs), 2)
def test_employee_cannot_create_organizations(self) -> None:
response = self._post(self.employee)
self.assertEqual(response.status_code, 403)
self.assertEqual(Organization.objects.count(), 1)
def test_instance_admin_without_memberships_can_create(self) -> None:
admin = HumanUser.objects.create_user(
email="admin@example.test", password="Admin-pass-123!", is_instance_admin=True
)
response = self._post(admin)
self.assertEqual(response.status_code, 201, response.content)
created = Organization.objects.get(public_id=response.json()["organizationPublicId"])
self.assertTrue(OrganizationMembership.objects.filter(organization=created, user=admin, role=EmployeeRole.OWNER).exists())
def test_empty_name_is_a_field_error(self) -> None:
response = self._post(self.owner, name=" ")
self.assertEqual(response.status_code, 400)
self.assertIn("name", response.json()["errors"])
self.assertEqual(Organization.objects.count(), 1)
def test_anonymous_is_rejected(self) -> None:
response = TenantAPIClient().post(URL, data=json.dumps({"name": "X"}), content_type="application/json")
self.assertIn(response.status_code, {401, 403})
def test_options_list_timezones_and_languages(self) -> None:
client = TenantAPIClient()
client.force_login(self.owner)
response = client.get(f"{URL}options/")
self.assertEqual(response.status_code, 200)
self.assertIn("Europe/Moscow", response.json()["timezones"])
self.assertTrue(response.json()["languages"])
@@ -50,6 +50,8 @@ COVERAGE_EXEMPT = {
# Настройки установки (адрес, по которому её открывают) — тоже одна
# строка на инстанс: их пишет мастер первого запуска, а не демо.
("identity", "instancesettings"),
# Состояние обновлений установки — одна строка на инстанс (updates/0001).
("updates", "updatestate"),
}
COVERAGE_EXEMPT_APPS = {"platform", "events"}
@@ -12,6 +12,7 @@ import secrets
from datetime import timedelta
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from chatballs.conversations import transports
@@ -39,7 +40,11 @@ def notifier_integrations(context):
provider__in=(IntegrationProvider.TELEGRAM, IntegrationProvider.MAX),
config__purpose=NOTIFIER_PURPOSE,
).exclude(secret="")
return qs.filter(organization=context.organization)
# Демо-подключения из демо-набора не опрашиваются: токены ненастоящие.
# Отсутствие ключа в JSON — тоже «не демо», поэтому isnull, а не exclude.
return qs.filter(organization=context.organization).filter(
Q(config__demoSeed__isnull=True) | Q(config__demoSeed=False)
)
def deep_link(integration: Integration, code: str) -> str:
@@ -41,11 +41,13 @@ class SupportPortalHostBoundaryMiddleware:
return True
# Адреса, которые человек задал сам: тот, на котором прошли мастер, и
# предыдущий — чтобы смена адреса в «Настройках» не выбрасывала того,
# кто её делает, до того как новый домен вообще заработал.
from chatballs.identity.instance_settings import accepted_hosts
# кто её делает, до того как новый домен вообще заработал. Промах
# перечитывает кэш: соседний процесс gunicorn мог ещё не увидеть адрес,
# который мастер записал секунду назад.
from chatballs.identity.instance_settings import host_is_accepted
try:
if host and host in {normalize_domain(item) for item in accepted_hosts()}:
if host_is_accepted(host):
return True
except Exception:
pass
+10
View File
@@ -64,6 +64,16 @@ def call_invite_route(token_hash: str) -> IngressRoute | None:
return _unique_route("call_invite_directory", token_hash)
def invitation_route(token_hash: str) -> IngressRoute | None:
"""Приглашение в организацию по хэшу токена из письма (/join).
Ссылка открывается без контекста — токен и есть единственный ключ. Каталог
(tenancy/0035) отдаёт организацию, а само приглашение читается уже в ней.
"""
return _unique_route("invitation_directory", token_hash)
def call_session_route(call_session_id: str) -> IngressRoute | None:
return _unique_route("call_session_directory", call_session_id)
@@ -0,0 +1,26 @@
# Состояние обновлений установки (updates_updatestate): одна строка на
# инсталляцию, не тенантная таблица, без RLS — как настройки установки (0028).
# Пишут её приложение (запрос установки, синхронизация статуса) и воркер
# (периодическая проверка канала релизов) — оба ролью app.
from django.db import migrations
GRANTS = """
GRANT SELECT, INSERT, UPDATE ON updates_updatestate
TO chatballs_runtime_app, chatballs_runtime_platform;
GRANT USAGE, SELECT ON SEQUENCE updates_updatestate_id_seq
TO chatballs_runtime_app, chatballs_runtime_platform;
"""
REVOKE = """
REVOKE ALL ON updates_updatestate
FROM chatballs_runtime_app, chatballs_runtime_platform;
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0033_organization_select_scope"),
("updates", "0001_initial"),
]
operations = [migrations.RunSQL(GRANTS, REVOKE)]
@@ -0,0 +1,55 @@
# Две дыры мультиорганизационности, обе на роли app.
#
# 1. Организации создаёт человек из интерфейса (кнопка «Добавить организацию»
# в переключателе, дизайн-базлайн v2, A1), а не только оператор платформы.
# Политика bootstrap из 0032 пускала INSERT роли app лишь до первой
# организации. Теперь строка вставляется в контексте своего же id: сервис
# заранее берёт id из последовательности (tenancy.lookup.reserve_organization_id),
# открывает tenant_atomic(id) и уже в нём пишет строку — ровно так, как
# это делал мастер первого запуска. Без контекста INSERT по-прежнему закрыт.
#
# 2. Ссылка-приглашение /join открывается без tenant-контекста: токен из
# письма — единственное, что есть. Таблица приглашений под RLS, и роль app
# без контекста не находила приглашение вовсе. Security-barrier каталог
# invitation_directory отдаёт по хэшу токена id организации и приглашения —
# по образцу call_invite_directory (0004).
from django.db import migrations
BOOTSTRAP_POLICY = "chatballs_organization_app_bootstrap"
CREATE_POLICY = "chatballs_organization_app_create"
FORWARD_SQL = f"""
DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization;
DROP POLICY IF EXISTS {CREATE_POLICY} ON identity_organization;
CREATE POLICY {CREATE_POLICY} ON identity_organization
FOR INSERT TO chatballs_runtime_app
WITH CHECK (id = chatballs.current_organization_id());
CREATE OR REPLACE VIEW chatballs.invitation_directory
WITH (security_barrier = true) AS
SELECT invitation.id AS resource_id,
invitation.organization_id,
invitation.token_hash AS lookup_key
FROM identity_organizationinvitation invitation;
ALTER VIEW chatballs.invitation_directory OWNER TO chatballs_schema;
REVOKE ALL ON chatballs.invitation_directory FROM PUBLIC;
GRANT SELECT ON chatballs.invitation_directory
TO chatballs_runtime_app, chatballs_runtime_platform;
"""
REVERSE_SQL = f"""
DROP VIEW IF EXISTS chatballs.invitation_directory;
DROP POLICY IF EXISTS {CREATE_POLICY} ON identity_organization;
DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization;
CREATE POLICY {BOOTSTRAP_POLICY} ON identity_organization
FOR INSERT TO chatballs_runtime_app
WITH CHECK (NOT chatballs.instance_has_organizations());
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0034_update_state_grants"),
]
operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)]
+48 -3
View File
@@ -1,11 +1,20 @@
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.db import DatabaseError, connection, transaction
from django.test import TransactionTestCase
from django.utils import timezone
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.ai.models import AIAgent, Knowledge
from chatballs.channels.models import Channel
from chatballs.identity.group_models import EmployeeGroup
from chatballs.identity.invitation_models import OrganizationInvitation
from chatballs.identity.invitation_service import (
invitation_preview,
issue_invitation,
pending_invitation_for_token,
)
from chatballs.identity.models import (
AuditEvent,
AuditResult,
@@ -14,7 +23,8 @@ from chatballs.identity.models import (
Organization,
OrganizationMembership,
)
from chatballs.tenancy.database import current_tenant_id, set_local_tenant
from chatballs.tenancy.database import current_tenant_id, set_local_tenant, tenant_atomic
from chatballs.tenancy.lookup import reserve_organization_id
from chatballs.tenancy.models import StorageReservation
from chatballs.testing import TenantAPIClient
@@ -429,7 +439,7 @@ class RowLevelSecurityTests(TransactionTestCase):
def test_app_role_reads_ingress_directory_and_cannot_add_organizations(self) -> None:
def test_app_role_reads_ingress_directory_and_adds_organizations_only_in_context(self) -> None:
# Каталоги входа доступны роли app (tenancy/0032): backend-app
# обходится без platform-соединения.
with transaction.atomic():
@@ -441,10 +451,45 @@ class RowLevelSecurityTests(TransactionTestCase):
[self.user.id],
)
self.assertEqual(cursor.fetchone()[0], self.first.id)
# Организации уже есть — INSERT для app закрыт политикой bootstrap.
# Без контекста INSERT организации для app закрыт (tenancy/0035).
with self.assertRaises(DatabaseError), transaction.atomic():
self._set_role("chatballs_runtime_app")
Organization.objects.create(name="Third", slug="rls-third")
# В контексте заранее выделенного id — открыт: так работает кнопка
# «Добавить организацию» (identity.organization_creation).
with transaction.atomic():
self._set_role("chatballs_runtime_app")
organization_id = reserve_organization_id()
with tenant_atomic(organization_id):
Organization(id=organization_id, name="Third", slug="rls-third").save(force_insert=True)
self.assertEqual(Organization.objects.get(pk=organization_id).slug, "rls-third")
self.assertTrue(Organization.objects.filter(slug="rls-third").exists())
# Чужой контекст не подходит: id строки обязан совпасть с контекстом.
with self.assertRaises(DatabaseError), transaction.atomic():
self._set_role("chatballs_runtime_app")
with tenant_atomic(self.first.id):
Organization(id=reserve_organization_id(), name="Fourth", slug="rls-fourth").save(force_insert=True)
def test_app_role_finds_invitation_by_token_without_context(self) -> None:
# Ссылка /join открывается без контекста: приглашение находит каталог
# invitation_directory (tenancy/0035), иначе роль app видела бы пустоту.
issued = issue_invitation(
organization=self.second,
email="invited@example.test",
role=EmployeeRole.EMPLOYEE,
expires_at=timezone.now() + timedelta(days=1),
created_by=None,
)
with transaction.atomic():
self._set_role("chatballs_runtime_app")
self.assertEqual(OrganizationInvitation.objects.count(), 0)
invitation = pending_invitation_for_token(issued.token)
self.assertIsNotNone(invitation)
self.assertEqual(invitation.id, issued.invitation.id)
self.assertEqual(invitation.organization.slug, "rls-second")
self.assertIsNone(pending_invitation_for_token("wrong-token"))
preview = invitation_preview(issued.token)
self.assertEqual(preview["organizationName"], "Second")
def test_platform_role_can_only_use_ingress_directory(self) -> None:
Whitespace-only changes.
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class UpdatesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "chatballs.updates"
verbose_name = "Обновления установки"
+106
View File
@@ -0,0 +1,106 @@
"""Канал релизов: страница релизов GitHub.
Установка спрашивает у GitHub последний релиз репозитория продукта и берёт
из него версию, заметки и ссылку на compose.yaml. Никаких токенов: релизы
публичные, лимита анонимных запросов хватает при проверке раз в несколько
часов.
"""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import datetime
from django.conf import settings
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.]+)?$")
_TIMEOUT_SECONDS = 15
@dataclass(frozen=True, slots=True)
class Release:
version: str
name: str
notes: str
published_at: datetime | None
compose_url: str
page_url: str
class ReleaseChannelError(Exception):
"""Канал релизов недоступен или ответил не тем, что ожидалось."""
def parse_version(tag: str) -> str | None:
version = tag.strip()
if version.startswith(("v", "V")):
version = version[1:]
return version if VERSION_RE.match(version) else None
def version_key(version: str) -> tuple:
"""Ключ сравнения: числовая тройка, затем метка (без метки — новее)."""
core, _, label = version.partition("-")
core = core.split(".")[:3]
numbers = tuple(int(part) for part in core if part.isdigit())
return (numbers, label == "", label)
def is_newer(candidate: str, current: str) -> bool:
if not VERSION_RE.match(candidate) or not VERSION_RE.match(current or ""):
# Dev-сборка без версии: обновлять нечего и не с чем сравнивать.
return False
return version_key(candidate) > version_key(current)
def expected_compose_url(version: str) -> str:
return f"https://github.com/{settings.CHATBALLS_UPDATE_REPO}/releases/download/v{version}/compose.yaml"
def fetch_latest_release() -> Release:
url = f"https://api.github.com/repos/{settings.CHATBALLS_UPDATE_REPO}/releases/latest"
request = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": f"chatballs/{settings.CHATBALLS_VERSION}",
},
)
try:
with urllib.request.urlopen(request, timeout=_TIMEOUT_SECONDS) as response:
payload = json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as error:
raise ReleaseChannelError(str(error)) from error
version = parse_version(str(payload.get("tag_name", "")))
if version is None:
raise ReleaseChannelError(f"unexpected tag {payload.get('tag_name')!r}")
compose_url = next(
(
str(asset.get("browser_download_url", ""))
for asset in payload.get("assets", [])
if asset.get("name") == "compose.yaml"
),
"",
)
if compose_url != expected_compose_url(version):
raise ReleaseChannelError("release has no compose.yaml at the expected address")
published = None
raw_published = str(payload.get("published_at", "")).strip()
if raw_published:
try:
published = datetime.fromisoformat(raw_published.replace("Z", "+00:00"))
except ValueError:
published = None
return Release(
version=version,
name=str(payload.get("name") or f"v{version}"),
notes=str(payload.get("body") or ""),
published_at=published,
compose_url=compose_url,
page_url=str(payload.get("html_url") or ""),
)
@@ -0,0 +1,57 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="UpdateState",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("latest_version", models.CharField(blank=True, default="", max_length=32)),
("latest_name", models.CharField(blank=True, default="", max_length=128)),
("latest_notes", models.TextField(blank=True, default="")),
("latest_published_at", models.DateTimeField(blank=True, null=True)),
("latest_compose_url", models.URLField(blank=True, default="", max_length=512)),
("latest_page_url", models.URLField(blank=True, default="", max_length=512)),
("checked_at", models.DateTimeField(blank=True, null=True)),
("check_error", models.CharField(blank=True, default="", max_length=500)),
("install_version", models.CharField(blank=True, default="", max_length=32)),
(
"install_status",
models.CharField(
choices=[
("IDLE", "Не запускалась"),
("REQUESTED", "Запрошена"),
("RUNNING", "Идёт"),
("DONE", "Завершена"),
("FAILED", "Ошибка"),
],
default="IDLE",
max_length=12,
),
),
("install_message", models.CharField(blank=True, default="", max_length=1000)),
("install_requested_at", models.DateTimeField(blank=True, null=True)),
("install_updated_at", models.DateTimeField(blank=True, null=True)),
(
"install_requested_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={"verbose_name": "Обновления установки"},
),
]
Whitespace-only changes.
+60
View File
@@ -0,0 +1,60 @@
"""Состояние обновлений установки: одна строка на инсталляцию.
Не тенантная таблица: версия у установки одна на всех. Проверка релизов
живёт в воркере, установку запрашивает администратор установки, а выполняет
сервис updater вне приложения (ADR-CHATBALLS-0049).
"""
from __future__ import annotations
from django.db import models
class InstallStatus(models.TextChoices):
IDLE = "IDLE", "Не запускалась"
REQUESTED = "REQUESTED", "Запрошена"
RUNNING = "RUNNING", "Идёт"
DONE = "DONE", "Завершена"
FAILED = "FAILED", "Ошибка"
class UpdateState(models.Model):
SINGLETON_PK = 1
# Что известно о последнем релизе на канале.
latest_version = models.CharField(max_length=32, blank=True, default="")
latest_name = models.CharField(max_length=128, blank=True, default="")
latest_notes = models.TextField(blank=True, default="")
latest_published_at = models.DateTimeField(null=True, blank=True)
latest_compose_url = models.URLField(max_length=512, blank=True, default="")
latest_page_url = models.URLField(max_length=512, blank=True, default="")
checked_at = models.DateTimeField(null=True, blank=True)
check_error = models.CharField(max_length=500, blank=True, default="")
# Последняя запрошенная установка.
install_version = models.CharField(max_length=32, blank=True, default="")
install_status = models.CharField(
max_length=12, choices=InstallStatus.choices, default=InstallStatus.IDLE
)
install_message = models.CharField(max_length=1000, blank=True, default="")
install_requested_at = models.DateTimeField(null=True, blank=True)
install_updated_at = models.DateTimeField(null=True, blank=True)
# Кто нажал кнопку — для аудита и подписи в интерфейсе.
install_requested_by = models.ForeignKey(
"identity.HumanUser", on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
)
class Meta:
verbose_name = "Обновления установки"
def __str__(self) -> str:
return f"updates:{self.latest_version or 'нет данных'}"
def save(self, *args, **kwargs):
self.pk = self.SINGLETON_PK
super().save(*args, **kwargs)
@classmethod
def load(cls) -> UpdateState:
obj, _ = cls.objects.get_or_create(pk=cls.SINGLETON_PK)
return obj
+179
View File
@@ -0,0 +1,179 @@
"""Проверка релизов и запрос установки обновления.
Приложение не перезапускает само себя: у публичного процесса нет и не должно
быть доступа к Docker. Оно лишь кладёт запрос в общий том
``CHATBALLS_UPDATES_DIR``, а сервис updater (deploy/updater) забирает его,
проверяет и выполняет ``docker compose pull && up`` уже снаружи. Состояние
установки updater пишет в тот же том файлом ``status``; приложение
переносит его в UpdateState при каждом чтении.
"""
from __future__ import annotations
import json
import logging
from datetime import timedelta
from pathlib import Path
from django.conf import settings
from django.utils import timezone
from chatballs.updates.github import (
ReleaseChannelError,
fetch_latest_release,
is_newer,
)
from chatballs.updates.models import InstallStatus, UpdateState
logger = logging.getLogger(__name__)
REQUEST_FILE = "request.json"
STATUS_FILE = "status.json"
HEARTBEAT_FILE = "heartbeat"
# Пока updater не отметился дольше этого, считаем, что установки из интерфейса нет.
HEARTBEAT_STALE_SECONDS = 90
_STATUS_MAP = {
"running": InstallStatus.RUNNING,
"done": InstallStatus.DONE,
"failed": InstallStatus.FAILED,
}
def updates_dir() -> Path:
return Path(settings.CHATBALLS_UPDATES_DIR)
def check_for_updates(*, force: bool = False) -> UpdateState:
"""Спросить канал релизов, если пора (или если попросили явно)."""
state = UpdateState.load()
interval = timedelta(seconds=settings.CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS)
if not force and state.checked_at and timezone.now() - state.checked_at < interval:
return state
try:
release = fetch_latest_release()
except ReleaseChannelError as error:
state.check_error = str(error)[:500]
state.checked_at = timezone.now()
state.save(update_fields=["check_error", "checked_at"])
logger.warning("Update check failed: %s", error)
return state
state.latest_version = release.version
state.latest_name = release.name
state.latest_notes = release.notes
state.latest_published_at = release.published_at
state.latest_compose_url = release.compose_url
state.latest_page_url = release.page_url
state.check_error = ""
state.checked_at = timezone.now()
state.save()
return state
def update_available(state: UpdateState) -> bool:
return bool(state.latest_version) and is_newer(state.latest_version, settings.CHATBALLS_VERSION)
def updater_online() -> bool:
"""Жив ли сервис updater: он отмечается в томе каждые несколько секунд."""
path = updates_dir() / HEARTBEAT_FILE
try:
age = timezone.now().timestamp() - path.stat().st_mtime
except OSError:
return False
return age < HEARTBEAT_STALE_SECONDS
def sync_install_status(state: UpdateState) -> UpdateState:
"""Перенести статус, который пишет updater, в состояние приложения."""
if state.install_status not in {InstallStatus.REQUESTED, InstallStatus.RUNNING}:
return state
path = updates_dir() / STATUS_FILE
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return state
if str(payload.get("version", "")) != state.install_version:
return state
status = _STATUS_MAP.get(str(payload.get("status", "")).lower())
if status is None:
return state
message = str(payload.get("message", ""))[:1000]
if status != state.install_status or message != state.install_message:
state.install_status = status
state.install_message = message
state.install_updated_at = timezone.now()
state.save(update_fields=["install_status", "install_message", "install_updated_at"])
return state
class InstallNotPossible(Exception):
def __init__(self, message: str, *, code: str) -> None:
super().__init__(message)
self.code = code
def request_install(*, actor, state: UpdateState | None = None) -> UpdateState:
"""Положить запрос на установку последней версии в том updater'а."""
from chatballs.i18n import t
state = sync_install_status(state or UpdateState.load())
if not update_available(state):
raise InstallNotPossible(t("updates.nothing_to_install"), code="nothing_to_install")
if not updater_online():
raise InstallNotPossible(t("updates.updater_offline"), code="updater_offline")
if state.install_status in {InstallStatus.REQUESTED, InstallStatus.RUNNING}:
raise InstallNotPossible(t("updates.install_in_progress"), code="install_in_progress")
directory = updates_dir()
directory.mkdir(parents=True, exist_ok=True)
request_path = directory / REQUEST_FILE
now = timezone.now()
request_path.write_text(
json.dumps(
{
"version": state.latest_version,
"compose_url": state.latest_compose_url,
"requested_at": now.isoformat(),
"requested_by": getattr(actor, "email", ""),
}
),
encoding="utf-8",
)
# Прежний статус относится к прошлой установке: убрать, чтобы не спутать.
try:
(directory / STATUS_FILE).unlink()
except OSError:
pass
state.install_version = state.latest_version
state.install_status = InstallStatus.REQUESTED
state.install_message = ""
state.install_requested_at = now
state.install_updated_at = now
state.install_requested_by = actor if getattr(actor, "pk", None) else None
state.save()
return state
def update_payload(state: UpdateState) -> dict[str, object]:
return {
"currentVersion": settings.CHATBALLS_VERSION,
"latestVersion": state.latest_version or None,
"latestName": state.latest_name,
"latestNotes": state.latest_notes,
"latestPublishedAt": state.latest_published_at.isoformat() if state.latest_published_at else None,
"latestPageUrl": state.latest_page_url,
"available": update_available(state),
"checkedAt": state.checked_at.isoformat() if state.checked_at else None,
"checkError": state.check_error,
"updaterOnline": updater_online(),
"install": {
"version": state.install_version or None,
"status": state.install_status,
"message": state.install_message,
"requestedAt": state.install_requested_at.isoformat() if state.install_requested_at else None,
"updatedAt": state.install_updated_at.isoformat() if state.install_updated_at else None,
},
}
+173
View File
@@ -0,0 +1,173 @@
"""Обновления из интерфейса: канал релизов, запрос установки, статус, права."""
from __future__ import annotations
import io
import json
import os
import time
from datetime import timedelta
from tempfile import TemporaryDirectory
from unittest import mock
from django.test import TestCase, override_settings
from django.utils import timezone
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
from chatballs.testing import TenantAPIClient
from chatballs.updates import github, services
from chatballs.updates.models import InstallStatus, UpdateState
PASSWORD = "Owner-Password-2026!"
RELEASE = {
"tag_name": "v1.5.0",
"name": "v1.5.0",
"body": "## Что нового\n- тест",
"published_at": "2026-09-13T10:00:00Z",
"html_url": "https://github.com/dartdavros/chatballs/releases/tag/v1.5.0",
"assets": [
{"name": "compose.yaml", "browser_download_url": "https://github.com/dartdavros/chatballs/releases/download/v1.5.0/compose.yaml"},
{"name": "release.env", "browser_download_url": "https://github.com/dartdavros/chatballs/releases/download/v1.5.0/release.env"},
],
}
def _response(payload: dict) -> mock.MagicMock:
"""urlopen отдаёт контекстный менеджер с read(): подделываем ровно это."""
response = mock.MagicMock()
response.__enter__.return_value = io.BytesIO(json.dumps(payload).encode("utf-8"))
return response
class VersionTests(TestCase):
def test_semantic_comparison(self) -> None:
self.assertTrue(github.is_newer("1.5.0", "1.4.0"))
self.assertTrue(github.is_newer("1.10.0", "1.9.9"))
self.assertTrue(github.is_newer("1.5.0", "1.5.0-rc1"))
self.assertFalse(github.is_newer("1.4.0", "1.4.0"))
self.assertFalse(github.is_newer("1.3.9", "1.4.0"))
# Dev-сборка: сравнивать не с чем.
self.assertFalse(github.is_newer("1.5.0", "dev"))
def test_tag_parsing(self) -> None:
self.assertEqual(github.parse_version("v1.5.0"), "1.5.0")
self.assertEqual(github.parse_version("1.5.0-rc1"), "1.5.0-rc1")
self.assertIsNone(github.parse_version("release-1"))
self.assertIsNone(github.parse_version("v1.5"))
@override_settings(CHATBALLS_VERSION="1.4.0", CHATBALLS_UPDATE_REPO="dartdavros/chatballs")
class UpdateCheckTests(TestCase):
def test_check_records_latest_release(self) -> None:
with mock.patch("urllib.request.urlopen", return_value=_response(RELEASE)):
state = services.check_for_updates(force=True)
self.assertEqual(state.latest_version, "1.5.0")
self.assertEqual(state.latest_compose_url, RELEASE["assets"][0]["browser_download_url"])
self.assertEqual(state.check_error, "")
self.assertTrue(services.update_available(state))
def test_check_respects_interval_unless_forced(self) -> None:
state = UpdateState.load()
state.checked_at = timezone.now() - timedelta(minutes=5)
state.save()
with mock.patch("urllib.request.urlopen") as fetch:
services.check_for_updates()
fetch.assert_not_called()
def test_channel_error_is_recorded_not_raised(self) -> None:
with mock.patch("urllib.request.urlopen", side_effect=OSError("offline")):
state = services.check_for_updates(force=True)
self.assertIn("offline", state.check_error)
self.assertFalse(services.update_available(state))
def test_release_without_compose_asset_is_refused(self) -> None:
release = {**RELEASE, "assets": []}
with mock.patch("urllib.request.urlopen", return_value=_response(release)):
state = services.check_for_updates(force=True)
self.assertIn("compose.yaml", state.check_error)
self.assertEqual(state.latest_version, "")
@override_settings(CHATBALLS_VERSION="1.4.0", CHATBALLS_UPDATE_REPO="dartdavros/chatballs")
class InstallRequestTests(TestCase):
def setUp(self) -> None:
self.tmp = TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.override = override_settings(CHATBALLS_UPDATES_DIR=self.tmp.name)
self.override.enable()
self.addCleanup(self.override.disable)
self.result = bootstrap_owner(email="update-owner@example.com", password=PASSWORD)
self.client = TenantAPIClient()
self.client.force_authenticate(self.result.owner)
with mock.patch("urllib.request.urlopen", return_value=_response(RELEASE)):
services.check_for_updates(force=True)
def _heartbeat(self) -> None:
(services.updates_dir() / services.HEARTBEAT_FILE).write_text("", encoding="utf-8")
def test_install_needs_a_live_updater(self) -> None:
response = self.client.post("/api/v1/instance/update/install/", {}, format="json")
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["code"], "updater_offline")
def test_install_writes_request_and_tracks_status_from_updater(self) -> None:
self._heartbeat()
response = self.client.post("/api/v1/instance/update/install/", {}, format="json")
self.assertEqual(response.status_code, 202, response.content)
request = json.loads((services.updates_dir() / services.REQUEST_FILE).read_text(encoding="utf-8"))
self.assertEqual(request["version"], "1.5.0")
self.assertEqual(request["compose_url"], RELEASE["assets"][0]["browser_download_url"])
self.assertEqual(response.json()["update"]["install"]["status"], InstallStatus.REQUESTED)
# Повторный запрос, пока идёт установка, отклоняется.
again = self.client.post("/api/v1/instance/update/install/", {}, format="json")
self.assertEqual(again.status_code, 409)
self.assertEqual(again.json()["code"], "install_in_progress")
# Updater пишет статус в том — приложение подхватывает его при чтении.
(services.updates_dir() / services.STATUS_FILE).write_text(
json.dumps({"status": "running", "version": "1.5.0", "message": "pulling"}), encoding="utf-8"
)
state = self.client.get("/api/v1/instance/update/").json()["update"]
self.assertEqual(state["install"]["status"], InstallStatus.RUNNING)
self.assertEqual(state["install"]["message"], "pulling")
(services.updates_dir() / services.STATUS_FILE).write_text(
json.dumps({"status": "done", "version": "1.5.0", "message": "updated"}), encoding="utf-8"
)
state = self.client.get("/api/v1/instance/update/").json()["update"]
self.assertEqual(state["install"]["status"], InstallStatus.DONE)
def test_status_for_another_version_is_ignored(self) -> None:
self._heartbeat()
self.client.post("/api/v1/instance/update/install/", {}, format="json")
(services.updates_dir() / services.STATUS_FILE).write_text(
json.dumps({"status": "done", "version": "1.4.9", "message": "stale"}), encoding="utf-8"
)
state = self.client.get("/api/v1/instance/update/").json()["update"]
self.assertEqual(state["install"]["status"], InstallStatus.REQUESTED)
def test_stale_heartbeat_means_offline(self) -> None:
path = services.updates_dir() / services.HEARTBEAT_FILE
path.write_text("", encoding="utf-8")
old = time.time() - services.HEARTBEAT_STALE_SECONDS - 5
os.utime(path, (old, old))
self.assertFalse(services.updater_online())
def test_organization_owner_reads_but_cannot_install_or_check(self) -> None:
other = Organization.objects.create(name="Other", slug="other-upd")
owner = HumanUser.objects.create_user(email="other-owner@example.com", password=PASSWORD)
OrganizationMembership.objects.create(organization=other, user=owner, role=EmployeeRole.OWNER, position_title="Owner")
client = TenantAPIClient()
client.force_authenticate(owner)
self.assertEqual(client.get("/api/v1/instance/update/").status_code, 200)
self.assertEqual(client.post("/api/v1/instance/update/check/", {}, format="json").status_code, 403)
self.assertEqual(client.post("/api/v1/instance/update/install/", {}, format="json").status_code, 403)
+59
View File
@@ -0,0 +1,59 @@
"""Обновления установки в интерфейсе: состояние, проверка, установка.
Читает менеджер любой организации (баннер показывается только администратору
установки, но версия видна всем менеджерам в «Платформе»), проверять канал и
запускать установку может только администратор установки.
"""
from __future__ import annotations
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.identity.audit import record_audit_event
from chatballs.identity.instance_access import InstanceSettingsPermission, IsInstanceAdmin
from chatballs.updates.models import UpdateState
from chatballs.updates.services import (
InstallNotPossible,
check_for_updates,
request_install,
sync_install_status,
update_payload,
)
class UpdateStateView(APIView):
permission_classes = [InstanceSettingsPermission]
def get(self, request: Request) -> Response:
state = sync_install_status(UpdateState.load())
return Response({"update": update_payload(state)})
class UpdateCheckView(APIView):
permission_classes = [IsInstanceAdmin]
def post(self, request: Request) -> Response:
state = sync_install_status(check_for_updates(force=True))
return Response({"update": update_payload(state)})
class UpdateInstallView(APIView):
permission_classes = [IsInstanceAdmin]
def post(self, request: Request) -> Response:
try:
state = request_install(actor=request.user)
except InstallNotPossible as error:
return Response({"detail": str(error), "code": error.code}, status=409)
record_audit_event(
action="updates.install_requested",
actor=request.user,
organization=None,
object_type="UpdateState",
object_id=state.install_version,
payload={"version": state.install_version},
request=request,
)
return Response({"update": update_payload(state)}, status=202)
@@ -33,6 +33,17 @@ if _delivery_mode not in {"CLOUD", "SELF_HOSTED"}:
"CHATBALLS_DELIVERY_MODE must be CLOUD or SELF_HOSTED"
)
CHATBALLS_DELIVERY_MODE = _delivery_mode
# Версия установки: CI передаёт её в образ при сборке релиза; в сборке из
# исходников остаётся «dev», и тогда обновлений из интерфейса нет — сравнивать
# не с чем.
CHATBALLS_VERSION = os.environ.get("CHATBALLS_VERSION", "dev").strip() or "dev"
# Канал релизов и обмен с сервисом updater (ADR-CHATBALLS-0049).
CHATBALLS_UPDATE_REPO = os.environ.get("CHATBALLS_UPDATE_REPO", "dartdavros/chatballs").strip()
CHATBALLS_UPDATES_DIR = os.environ.get("CHATBALLS_UPDATES_DIR", "/run/chatballs/updates")
CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS = int(
os.environ.get("CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS", str(6 * 3600))
)
ALLOWED_HOSTS = env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1"])
if TESTING:
ALLOWED_HOSTS.extend(["testserver", ".localhost"])
@@ -67,6 +78,7 @@ INSTALLED_APPS = [
"chatballs.events",
"chatballs.support_portals",
"chatballs.calls",
"chatballs.updates",
# django-channels НЕ добавляется в INSTALLED_APPS: его app label «channels»
# конфликтует с доменным chatballs.channels, а без runserver-оверрайда
# (сервер — uvicorn) библиотеке достаточно CHANNEL_LAYERS.
@@ -14,6 +14,9 @@ urlpatterns = [
path("api/v1/auth/", include("chatballs.identity.auth_urls")),
path("api/v1/setup/", include("chatballs.identity.setup_urls")),
path("api/v1/instance/", include("chatballs.identity.instance_urls")),
# Создание организации из интерфейса: адрес без uuid, контекст открывает
# сам сервис. Маршруты с uuid ниже его не перехватывают.
path("api/v1/organizations/", include("chatballs.identity.organization_urls")),
path(
"api/v1/demo-media/avatars/<str:name>",
DemoMediaView.as_view(),
+31 -2
View File
@@ -12,7 +12,7 @@ import { api, setActiveOrganization } from "./api/client";
import { fetchAgentDirectory } from "./features/agents/model";
import { canAccess, defaultRoute, isManager } from "./auth/access";
import { activateOrganization, clearOrganizationPreference } from "./auth/session";
import { AuthChangePassword, AuthJoin, AuthJoinGuest, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
import { AuthChangePassword, AuthChooseOrganization, AuthJoin, AuthJoinGuest, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
import { Shell } from "./layout/Shell";
import { pathFromRoute, routeFromPath } from "./router";
import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui";
@@ -40,6 +40,9 @@ export function App() {
const [user, setUser] = useState<SessionUser | null>(null);
const [organizationPublicId, setOrganizationPublicId] = useState<string | null>(initialRoute.organizationPublicId);
const [totpChallenge, setTotpChallenge] = useState<AuthChallenge | null>(null);
// Учётная запись с несколькими организациями после входа выбирает, с какой
// начать. Ссылка на конкретную организацию экран выбора минует.
const [organizationChoice, setOrganizationChoice] = useState<AuthenticatedUser | null>(null);
const [recovering, setRecovering] = useState(false);
const [resetting, setResetting] = useState(() => window.location.pathname === "/reset-password");
// Ссылка из письма-приглашения (/join?token=…): токен запоминается до входа
@@ -132,6 +135,11 @@ export function App() {
}, [loadData, user]);
const landAfterAuth = useCallback((nextIdentity: AuthenticatedUser) => {
if (!initialRoute.organizationPublicId && nextIdentity.memberships.length > 1) {
acceptServerLanguage(nextIdentity.language);
setOrganizationChoice(nextIdentity);
return;
}
const activeUser = useIdentity(nextIdentity, initialRoute.organizationPublicId);
if (activeUser) {
navigate(defaultRoute(activeUser), null, true, activeUser.organizationPublicId);
@@ -163,6 +171,24 @@ export function App() {
const joinUseLogin = useCallback(() => setJoinNeedsLogin(true), []);
const chooseOrganization = useCallback((organizationId: string) => {
if (!organizationChoice) return;
setOrganizationChoice(null);
const activeUser = useIdentity(organizationChoice, organizationId);
if (activeUser) {
navigate(defaultRoute(activeUser), null, true, activeUser.organizationPublicId);
}
}, [navigate, organizationChoice, useIdentity]);
// Новая организация создана: сервер вернул учётную запись с обновлённым
// списком членств — переключаемся в неё сразу, как после приглашения.
const finishOrganizationCreate = useCallback((nextIdentity: AuthenticatedUser, organizationId: string) => {
const activeUser = useIdentity(nextIdentity, organizationId);
if (activeUser) {
navigate(defaultRoute(activeUser), null, false, activeUser.organizationPublicId);
}
}, [navigate, useIdentity]);
const cancelJoin = useCallback(() => {
setJoinToken(null);
if (user) navigate(defaultRoute(user), null, true, user.organizationPublicId);
@@ -170,6 +196,7 @@ export function App() {
async function logout() {
await api("/api/v1/auth/logout/", { method: "POST" }).catch(() => undefined);
setOrganizationChoice(null);
setIdentity(null);
setUser(null);
setOrganizationPublicId(null);
@@ -194,6 +221,8 @@ export function App() {
<ConfigProvider theme={antdTheme} locale={antdLocale}>
{totpChallenge ? (
<AuthTotpCode challenge={totpChallenge} onVerified={(nextUser) => { setTotpChallenge(null); landAfterAuth(nextUser); }} />
) : organizationChoice ? (
<AuthChooseOrganization identity={organizationChoice} onChoose={chooseOrganization} onLogout={logout} />
) : !identity ? (
joinToken && !joinNeedsLogin && !needsSetup ? (
<AuthJoinGuest token={joinToken} onUseLogin={joinUseLogin} onRegistered={finishJoin} />
@@ -217,7 +246,7 @@ export function App() {
) : !canAccess(user, navigation.route) ? (
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
) : (
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} />
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} onOrganizationCreated={finishOrganizationCreate} />
)}
</ConfigProvider>
);
+8
View File
@@ -17,10 +17,18 @@ export function hasCapability(user: SessionUser, capability: string): boolean {
return user.capabilities.includes(capability);
}
/** Кто заводит новые организации: администратор установки и тот, кто где-либо
* владелец или администратор. Роль в текущей организации не решает —
* сотрудник здесь может быть владельцем в другой. Сервер проверяет то же. */
export function canCreateOrganization(user: SessionUser): boolean {
return user.isInstanceAdmin || user.memberships.some((membership) => membership.role === "OWNER" || membership.role === "ADMIN");
}
export function canAccess(user: SessionUser, route: RouteKey): boolean {
// «Настройки» — настройки организации: только владелец и админ. Личные
// параметры сотрудника живут на странице «Профиль» (дизайн-базлайн v2).
if (route === "profile") return true;
if (route === "organizationCreate") return canCreateOrganization(user);
if (isManager(user)) return true;
return EMPLOYEE_ROUTES.has(route);
}
@@ -38,7 +38,7 @@ export function OrganizationLogoField({
<input
ref={input}
type="file"
accept="image/png,image/jpeg,image/webp"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
@@ -5,6 +5,7 @@ import { LANGUAGES } from "@chatballs/shared";
import { api, ApiError } from "../../api/client";
import { ChannelGlyph } from "../../shared/badges";
import { SelectField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { EmptyState, ErrorScreen, LoadingState } from "../../shared/ui";
import { BackLink, Button, CopyButton } from "../../shared/ui-controls";
@@ -509,25 +510,16 @@ function AssignmentCard({ card, groups, canManage, busy, apply }: {
? <input value={name} onChange={(event) => setName(event.target.value)} onBlur={() => { if (dirty) void apply({ name: name.trim() }); }} />
: <span className="agent-field-static">{card.name}</span>}
</label>
<label className="agent-field is-select">
<span>{t("common.group")}</span>
<span className="agent-field-control">
<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />
{canManage ? (
<select
disabled={busy}
value={card.groupId === null ? "" : String(card.groupId)}
onChange={(event) => void apply({ groupId: event.target.value ? Number(event.target.value) : null })}
>
<option value="">{t("common.no_group")}</option>
{groups.map((group) => <option value={String(group.id)} key={group.id}>{group.name}</option>)}
</select>
) : (
<span className="agent-field-static">{card.groupName ?? t("common.no_group")}</span>
)}
<Icon name="chevron" size={14} strokeWidth={2.2} />
</span>
</label>
<SelectField
adornment={<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />}
disabled={busy}
label={t("common.group")}
readOnly={!canManage}
readOnlyText={card.groupName ?? t("common.no_group")}
value={card.groupId === null ? "" : String(card.groupId)}
onChange={(next) => void apply({ groupId: next ? Number(next) : null })}
options={[["", t("common.no_group")], ...groups.map((group) => [String(group.id), group.name] as [string, string])]}
/>
</div>
</section>
);
@@ -558,24 +550,16 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
<h3>{t("common.model")}</h3>
<p>{t("ai.provider_key_lives_under_settings")}</p>
<div className="agent-side-fields">
<label className={`agent-field is-select ${missingProvider ? "is-invalid" : ""}`}>
<span>{t("ai.provider")}</span>
<span className="agent-field-control">
{canManage ? (
<select
disabled={busy}
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
onChange={(event) => void apply({ providerIntegrationId: event.target.value ? Number(event.target.value) : null })}
>
<option value="">{t("ai.not_selected")}</option>
{providers.map((item) => <option value={String(item.id)} key={item.id}>{item.name}</option>)}
</select>
) : (
<span className="agent-field-static">{providerName || t("ai.not_selected")}</span>
)}
<Icon name="chevron" size={14} strokeWidth={2.2} />
</span>
</label>
<SelectField
disabled={busy}
invalid={missingProvider}
label={t("ai.provider")}
readOnly={!canManage}
readOnlyText={providerName || t("ai.not_selected")}
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
onChange={(next) => void apply({ providerIntegrationId: next ? Number(next) : null })}
options={[["", t("ai.not_selected")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
/>
<label className="agent-field is-model">
<span>{t("common.model")}</span>
<span className="agent-field-control">
@@ -352,23 +352,6 @@
gap: 14px;
}
.agent-create-modal .readonly-field span,
.agent-knowledge-modal .readonly-field span {
margin-bottom: 6px;
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
line-height: normal;
}
.agent-create-modal .readonly-field input,
.agent-create-modal .select-like select {
height: 36px;
background: var(--n-10);
font-size: 13.5px;
line-height: normal;
}
.agent-create-actions {
display: flex;
justify-content: flex-end;
@@ -800,7 +783,6 @@
.agent-field textarea,
.agent-field input,
.agent-field select,
.agent-field .agent-field-static {
width: 100%;
padding: 0 12px;
@@ -814,7 +796,6 @@
}
.agent-field input,
.agent-field select,
.agent-field > .agent-field-static {
height: 36px;
line-height: 34px;
@@ -859,7 +840,6 @@
/* Фокус поля — как в кадре G2: акцентная рамка, кольцо 3px и подложка карточки. */
.agent-field textarea:focus,
.agent-field input:focus,
.agent-field select:focus,
.agent-create-modal .form-field.is-editable input:focus,
.agent-create-modal .select-like select:focus {
border-color: var(--primary);
@@ -867,7 +847,9 @@
box-shadow: 0 0 0 3px var(--primary-bg);
}
.agent-field-control:focus-within {
/* Тот же фокус для общего селекта карточки: с боксом (группа) и без (провайдер). */
.agent-side-fields .select-like select:focus,
.agent-side-fields .select-box:focus-within {
border-color: var(--primary);
background: var(--surface-card);
box-shadow: 0 0 0 3px var(--primary-bg);
@@ -1028,23 +1010,6 @@
font-size: 13.5px;
}
.agent-field.is-invalid .agent-field-control {
border-color: var(--error-border);
color: var(--error-text);
}
.agent-field-control select {
flex: 1;
height: 34px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font-size: 13.5px;
appearance: none;
cursor: pointer;
}
.agent-field-control .agent-field-static {
flex: 1;
height: auto;
@@ -505,7 +505,15 @@
.knowledge-table .row-actions { text-align: right; }
/* --- Подвал таблицы --- */
/* --- Пустое состояние (кадр KB1): блок по центру рабочей области --- */
.knowledge-empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 40px;
}
.knowledge-empty > div {
width: 480px;
@@ -0,0 +1,57 @@
import { Icon } from "../../shared/icons";
import { roleLabel } from "../../shared/ui";
import { AuthFrame } from "./AuthFrame";
import { t } from "../../i18n";
import type { AuthenticatedUser } from "../../types";
// Выбор организации после входа — для тех, кто состоит в нескольких. Экран
// в той же матовой рамке, что вход и приглашение: список организаций
// строками «логотип · название · роль». Ссылка на конкретную организацию
// этот экран минует: адрес уже сказал, куда идти.
function initials(name: string): string {
return name.trim().split(/\s+/).map((part) => part[0] ?? "").join("").slice(0, 2).toUpperCase() || "CB";
}
export function AuthChooseOrganization({ identity, onChoose, onLogout }: {
identity: AuthenticatedUser;
onChoose: (organizationPublicId: string) => void;
onLogout: () => void;
}) {
const logoutLink = (
<button type="button" className="auth-back-login" onClick={onLogout}><Icon name="logout" size={14} />{t("auth.choose_organization_logout")}</button>
);
return (
<AuthFrame
title={t("auth.choose_organization_title")}
subtitle={t("auth.choose_organization_subtitle", { name: identity.fullName || identity.email })}
logo="pulse"
width={420}
note={logoutLink}
>
<ul className="auth-card auth-org-list">
{identity.memberships.map((membership) => (
<li key={membership.organizationPublicId}>
<button
type="button"
className="auth-org-item"
onClick={() => onChoose(membership.organizationPublicId)}
>
<span className={`auth-org-mark ${membership.organizationLogoUrl ? "has-logo" : ""}`}>
{membership.organizationLogoUrl
? <img src={membership.organizationLogoUrl} alt="" />
: initials(membership.organizationName)}
</span>
<span className="auth-org-copy">
<strong>{membership.organizationName}</strong>
<small>{membership.positionTitle || roleLabel(membership.role)}</small>
</span>
<Icon name="chevronRight" size={16} />
</button>
</li>
))}
</ul>
</AuthFrame>
);
}
@@ -1,4 +1,5 @@
export { AuthChangePassword } from "./AuthChangePassword";
export { AuthChooseOrganization } from "./AuthChooseOrganization";
export { AuthJoin } from "./AuthJoin";
export { AuthJoinGuest } from "./AuthJoinGuest";
export { AuthLogin } from "./AuthLogin";
@@ -603,6 +603,102 @@
font-weight: 600;
}
/* Выбор организации после входа: строки «логотип · название · роль» в той же
матовой рамке, что и вход. Плитка логотипа — как знак в сайдбаре (28px),
только крупнее; строка целиком — кнопка. */
.auth-org-list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.auth-org-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px 10px 10px;
color: var(--n-1);
background: var(--surface-card);
border: 1px solid var(--n-8);
border-radius: 10px;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.auth-org-item:hover,
.auth-org-item:focus-visible {
background: var(--primary-bg);
border-color: var(--primary-border);
outline: none;
}
.auth-org-item > svg {
flex: none;
color: var(--n-5);
}
.auth-org-item:hover > svg {
color: var(--primary-text);
}
.auth-org-mark {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
overflow: hidden;
color: var(--primary-text);
background: var(--primary-bg);
border-radius: 9px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
}
.auth-org-mark.has-logo {
background: var(--surface-card);
border: 1px solid var(--n-7);
}
.auth-org-mark img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.auth-org-copy {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
gap: 2px;
}
.auth-org-copy strong {
overflow: hidden;
color: var(--n-1);
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.auth-org-copy small {
overflow: hidden;
color: var(--n-4);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.auth-back-login {
display: inline-flex;
align-items: center;
@@ -0,0 +1,167 @@
import { type FormEvent, useEffect, useMemo, useState } from "react";
import { ApiError } from "../../api/client";
import { OrganizationLogoField } from "../administration/OrganizationLogoField";
import { FormField, SelectField } from "../../shared/form-controls";
import { BackLink, Button } from "../../shared/ui-controls";
import { timezoneLabel } from "../../shared/utils";
import { t } from "../../i18n";
import type { AuthenticatedUser, SessionUser } from "../../types";
import {
createOrganization,
loadOrganizationCreateOptions,
uploadNewOrganizationLogo,
type OrganizationCreateOptions,
type OrganizationDraft,
} from "./api";
// Страница создания организации (переключатель A1 → «Добавить организацию»).
// Те же поля, что в «Настройках → Организация»: имя, часовой пояс, валюта,
// язык, логотип. Создавший становится владельцем и сразу переключается в
// новую организацию — ответ сервера уже содержит обновлённый список членств.
type FieldErrors = Partial<Record<keyof OrganizationDraft, string>>;
export function OrganizationCreatePage({ user, onCreated, onBack }: {
user: SessionUser;
onCreated: (identity: AuthenticatedUser, organizationPublicId: string) => void;
onBack: () => void;
}) {
const [options, setOptions] = useState<OrganizationCreateOptions | null>(null);
const [draft, setDraft] = useState<OrganizationDraft>({
name: "",
// Новая организация наследует региональные параметры текущей: чаще всего
// человек заводит вторую компанию там же, где первую.
timezone: "Europe/Moscow",
currency: "RUB",
language: "",
});
const [logo, setLogo] = useState<File | null>(null);
const [errors, setErrors] = useState<FieldErrors>({});
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
let active = true;
loadOrganizationCreateOptions()
.then((payload) => { if (active) setOptions(payload); })
.catch((requestError) => {
if (active) setError(requestError instanceof ApiError ? requestError.message : t("common.request_failed"));
});
return () => { active = false; };
}, []);
const logoPreview = useMemo(() => (logo ? URL.createObjectURL(logo) : null), [logo]);
useEffect(() => () => { if (logoPreview) URL.revokeObjectURL(logoPreview); }, [logoPreview]);
const valid = draft.name.trim() !== "" && !submitting;
async function submit(event: FormEvent) {
event.preventDefault();
if (!valid) return;
setSubmitting(true);
setError("");
setErrors({});
let created;
try {
created = await createOrganization(draft);
} catch (requestError) {
if (requestError instanceof ApiError) {
const fieldErrors = (requestError.payload as { errors?: FieldErrors }).errors;
if (fieldErrors) setErrors(fieldErrors);
setError(requestError.message);
} else {
setError(t("organizations.create_failed"));
}
setSubmitting(false);
return;
}
if (logo) {
// Логотип — уже в созданную организацию. Его неудача организацию не
// отменяет: человек попадает в неё и доложит логотип в «Настройках».
try {
await uploadNewOrganizationLogo(created.organizationPublicId, logo);
} catch {
window.setTimeout(() => window.alert(t("organizations.logo_failed")), 0);
}
}
onCreated(created.user, created.organizationPublicId);
}
const timezones = options?.timezones ?? [draft.timezone];
const languages = options?.languages ?? [];
return (
<div className="organization-create">
<BackLink label={user.organizationName || "Chatballs"} onClick={onBack} />
<header className="organization-create-head">
<h2>{t("organizations.create_title")}</h2>
<p>{t("organizations.create_lead")}</p>
</header>
<form className="administration-card administration-organization" onSubmit={submit}>
<OrganizationLogoField
logoUrl={logoPreview}
name={draft.name}
disabled={false}
saving={submitting}
onUpload={setLogo}
onRemove={() => setLogo(null)}
/>
<div className="administration-fields">
<div className="administration-field-wide">
<FormField
label={t("admin.organization_name")}
value={draft.name}
placeholder={t("organizations.name_placeholder")}
error={errors.name}
onChange={(name) => setDraft({ ...draft, name })}
/>
</div>
<SelectField
label={t("admin.time_zone")}
value={draft.timezone}
onChange={(timezone) => setDraft({ ...draft, timezone })}
options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])}
/>
<SelectField
label={t("admin.currency")}
value={draft.currency}
onChange={(currency) => setDraft({ ...draft, currency })}
options={[["RUB", t("admin.russian_rouble_rub")]]}
/>
</div>
<div className="appearance-row administration-language">
<span>{t("settings.language")}</span>
<div className="appearance-theme-options">
<button
className={draft.language === "" ? "active" : ""}
type="button"
onClick={() => setDraft({ ...draft, language: "" })}
>
{t("settings.language_as_installation")}
</button>
{languages.map((item) => (
<button
className={draft.language === item.code ? "active" : ""}
key={item.code}
lang={item.code}
type="button"
onClick={() => setDraft({ ...draft, language: item.code })}
>
{item.label}
</button>
))}
</div>
</div>
<p className="settings-section-note">{t("settings.language_org_hint")}</p>
{error && <div className="administration-message error" role="alert">{error}</div>}
<div className="administration-actions">
<Button variant="secondary" disabled={submitting} onClick={onBack}>{t("common.cancel")}</Button>
<Button type="submit" variant="primary" icon="plus" disabled={!valid}>
{submitting ? t("organizations.creating") : t("organizations.create_submit")}
</Button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,44 @@
import { api, apiUpload } from "../../api/client";
import type { AuthenticatedUser } from "../../types";
// Создание организации идёт без организации в адресе: её ещё нет. Клиент
// такие пути не переписывает (namespace «organizations» не тенантный).
const BASE = "/api/v1/organizations/";
export type OrganizationCreateOptions = {
timezones: string[];
languages: Array<{ code: string; label: string }>;
currencies: string[];
};
export type OrganizationDraft = {
name: string;
timezone: string;
currency: string;
// Пустая строка — «как в установке».
language: string;
};
export type OrganizationCreated = {
user: AuthenticatedUser;
organizationPublicId: string;
};
export function loadOrganizationCreateOptions(): Promise<OrganizationCreateOptions> {
return api<OrganizationCreateOptions>(`${BASE}options/`);
}
export function createOrganization(draft: OrganizationDraft): Promise<OrganizationCreated> {
return api<OrganizationCreated>(BASE, {
method: "POST",
body: JSON.stringify(draft),
});
}
/** Логотип загружается уже в созданную организацию — по её полному адресу,
* а не по активной: интерфейс ещё в прежней. */
export function uploadNewOrganizationLogo(organizationPublicId: string, file: File): Promise<unknown> {
const form = new FormData();
form.append("file", file);
return apiUpload(`${BASE}${organizationPublicId}/company/administration/logo/`, form);
}
@@ -0,0 +1,29 @@
/* Страница создания организации (переключатель A1 → «Добавить организацию»).
Карточка формы — та же, что «Настройки → Организация»; страница лишь даёт
ей заголовок, подводку и ширину раздела настроек. */
.organization-create {
width: 100%;
max-width: 720px;
margin: 0 auto;
padding: 24px 28px 40px;
}
.organization-create-head {
margin: 14px 0 22px;
}
.organization-create-head h2 {
margin: 0;
color: var(--n-1);
font-size: 20px;
font-weight: 700;
letter-spacing: -0.01em;
}
.organization-create-head p {
max-width: 560px;
margin: 5px 0 0;
color: var(--n-4);
font-size: 13px;
line-height: 1.5;
}
@@ -154,23 +154,8 @@
color: var(--error-text);
}
/* Поля профиля: подпись 12.5/600 --n-3, поле 36px на подложке --n-10. */
.profile-page .readonly-field span {
margin-bottom: 6px;
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
}
.profile-page .readonly-field input,
.profile-page .form-field.is-editable input {
height: 36px;
color: var(--n-1);
background: var(--n-10);
border-color: var(--n-7);
font-size: 13.5px;
}
/* Поле профиля моноширинным набором (идентификаторы) — на полступени мельче.
Сам скин поля общий, из shared/form-controls.css. */
.profile-page .readonly-field input.mono {
font-size: 13px;
@@ -866,11 +851,6 @@
gap: 14px;
}
.profile-page.is-mobile .readonly-field input {
height: 44px;
font-size: 15px;
}
.profile-page.is-mobile .readonly-field input.mono {
font-size: 14px;
}
@@ -11,6 +11,7 @@ import {
type EmailPayload,
type InstancePayload,
} from "./instance";
import { UpdatesCard } from "../updates/UpdatesCard";
import { t } from "../../i18n";
// «Платформа» (Настройки): свойства инсталляции, а не организации. Адрес
@@ -62,6 +63,7 @@ export function PlatformSettingsCard({ canManage }: { canManage: boolean }) {
return (
<>
<UpdatesCard canManage={canManage} />
<AddressCard canManage={canManage} current={current} onSaved={setCurrent} />
<LanguageCard canManage={canManage} current={current} onSaved={setCurrent} />
<EmailCard canManage={canManage} current={current} onSaved={setCurrent} />
@@ -229,33 +229,14 @@
font-size: 12.5px;
}
/* Карточка и поля раздела: подпись 12.5/600 --n-3, поле 36px на подложке
--n-10 (кадры N1/N6). */
/* Карточка раздела (кадры N1/N6). Поля — общий скин из
shared/form-controls.css, своей копии у раздела больше нет. */
.settings-content .administration-card {
padding: 22px 24px;
box-shadow: none;
}
.settings-content .readonly-field span {
margin-bottom: 6px;
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
}
.settings-content .readonly-field input,
.settings-content .readonly-field select,
.settings-content .select-like input,
.settings-content .select-like select,
.settings-content .form-field.is-editable input {
height: 36px;
color: var(--n-1);
background: var(--n-10);
border-color: var(--n-7);
font-size: 13.5px;
}
/* Кнопки раздела: primary и вторичная 34px радиусом 8 (кадры N1–N7). */
.settings-content .primary-button,
@@ -582,6 +563,40 @@
справа, снизу разделитель (кадр N6). Один стандарт на все разделы —
«Хранилище файлов», «Платформа», relay в «Голосовых и звонках». */
.settings-facts {
display: grid;
grid-template-columns: 180px 1fr;
gap: 6px 16px;
margin: 14px 0 0;
font-size: 13.5px;
}
.settings-facts > div {
display: contents;
}
.settings-facts dt {
color: var(--n-4);
}
.settings-facts dd {
margin: 0;
color: var(--n-1);
}
.settings-card-note {
margin: 12px 0 0;
color: var(--n-3);
font-size: 13px;
}
.settings-card-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
}
.settings-card-head {
display: flex;
align-items: center;
@@ -879,9 +894,6 @@
padding-inline: 14px;
}
.settings-content .readonly-field input,
.settings-content .readonly-field select,
.settings-content .select-like select,
.settings-content .settings-group-create input {
height: 44px;
font-size: 15px;
@@ -0,0 +1,108 @@
import { useState } from "react";
import { DecisionDialog } from "../../shared/DecisionDialog";
import { Icon, LogoSpinner } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import { installUpdate } from "./api";
import { useUpdateInfo } from "./useUpdateInfo";
import { t } from "../../i18n";
// Полоса поверх рабочей области у администратора установки: есть новая
// версия, идёт установка, установка завершена или сорвалась. Та же полоса,
// что у установки демо-данных (ADR-CHATBALLS-0049).
export function UpdateBanner({ enabled }: { enabled: boolean }) {
const { info, setInfo, installing, unreachable, finished } = useUpdateInfo(enabled);
const [dismissed, setDismissed] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [errorText, setErrorText] = useState("");
if (!enabled) return null;
async function install() {
setBusy(true);
setErrorText("");
try {
setInfo(await installUpdate());
setConfirming(false);
} catch (error) {
setErrorText(error instanceof Error ? error.message : t("common.could_not_complete_action"));
} finally {
setBusy(false);
}
}
const version = info?.install.version ?? info?.latestVersion ?? "";
if (installing) {
const stage = unreachable ? "restarting" : info?.install.message || "starting";
const stageKey = (["downloading", "pulling", "restarting"].includes(stage) ? stage : "starting") as "downloading" | "pulling" | "restarting" | "starting";
return (
<div className="demo-install-banner update-banner">
<LogoSpinner size={17} />
<span>{t("updates.installing", { version })} {t(`updates.stage_${stageKey}`)}</span>
</div>
);
}
if (finished && info?.install.status === "DONE" && dismissed !== `done:${version}`) {
return (
<div className="demo-install-banner update-banner is-done">
<Icon name="check" size={15} strokeWidth={2.2} />
<span>{t("updates.done", { version })}</span>
<button type="button" onClick={() => window.location.reload()}>{t("common.show")}</button>
<button className="demo-install-close" type="button" onClick={() => setDismissed(`done:${version}`)} aria-label={t("common.hide")}>
<Icon name="close" size={14} strokeWidth={2} />
</button>
</div>
);
}
if (info?.install.status === "FAILED" && dismissed !== `failed:${version}`) {
return (
<div className="demo-install-banner update-banner is-failed">
<Icon name="warning" size={15} strokeWidth={2} />
<span>{t("updates.failed", { version })}{info.install.message ? `: ${info.install.message}` : ""}</span>
<button className="demo-install-close" type="button" onClick={() => setDismissed(`failed:${version}`)} aria-label={t("common.hide")}>
<Icon name="close" size={14} strokeWidth={2} />
</button>
</div>
);
}
if (!info?.available || dismissed === `available:${info.latestVersion}`) return null;
return (
<>
<div className="demo-install-banner update-banner">
<Icon name="download" size={15} strokeWidth={2} />
<span>
{t("updates.available_title", { version: info.latestVersion ?? "" })}
{!info.updaterOnline && ` · ${t("updates.updater_offline")}`}
</span>
{info.latestPageUrl && (
<a className="link is-neutral" href={info.latestPageUrl} target="_blank" rel="noreferrer">{t("updates.whats_new")}</a>
)}
<button type="button" disabled={!info.updaterOnline} onClick={() => setConfirming(true)}>{t("updates.install")}</button>
<button className="demo-install-close" type="button" onClick={() => setDismissed(`available:${info.latestVersion}`)} aria-label={t("common.hide")}>
<Icon name="close" size={14} strokeWidth={2} />
</button>
</div>
<DecisionDialog
open={confirming}
tone="warning"
icon="download"
title={t("updates.confirm_title", { version: info.latestVersion ?? "" })}
description={errorText || t("updates.confirm_text")}
onClose={() => setConfirming(false)}
actions={(
<>
<Button variant="secondary" disabled={busy} onClick={() => setConfirming(false)}>{t("common.cancel")}</Button>
<Button variant="primary" disabled={busy} onClick={() => void install()}>{t("updates.install")}</Button>
</>
)}
/>
</>
);
}
@@ -0,0 +1,82 @@
import { useState } from "react";
import { Button } from "../../shared/ui-controls";
import { shortDateTime } from "../../shared/utils";
import { checkUpdates, installUpdate } from "./api";
import { useUpdateInfo } from "./useUpdateInfo";
import { t } from "../../i18n";
// Карточка «Обновления» в разделе «Платформа»: версия установки, последняя
// версия на канале релизов, проверка и установка по кнопке. Сама установка
// идёт снаружи приложения (ADR-CHATBALLS-0049), здесь только её состояние.
export function UpdatesCard({ canManage }: { canManage: boolean }) {
const { info, setInfo, installing, unreachable } = useUpdateInfo(true);
const [busy, setBusy] = useState(false);
const [errorText, setErrorText] = useState("");
async function run(action: () => Promise<typeof info>) {
setBusy(true);
setErrorText("");
try {
const next = await action();
if (next) setInfo(next);
} catch (error) {
setErrorText(error instanceof Error ? error.message : t("common.could_not_complete_action"));
} finally {
setBusy(false);
}
}
if (!info && !unreachable) return null;
const latest = info?.latestVersion;
const checked = info?.checkedAt ? t("updates.checked_at", { time: shortDateTime(info.checkedAt) }) : t("updates.never_checked");
const status = info?.install.status;
return (
<div className="administration-card">
<div className="settings-card-head">
<div>
<strong>{t("updates.card_title")}</strong>
<small>{t("updates.card_hint")}</small>
</div>
{canManage && (
<Button variant="secondary" disabled={busy || installing} onClick={() => void run(checkUpdates)}>{t("updates.check")}</Button>
)}
</div>
<dl className="settings-facts">
<div><dt>{t("updates.current_version")}</dt><dd>{info?.currentVersion ?? "—"}</dd></div>
<div>
<dt>{t("updates.latest_version")}</dt>
<dd>
{latest ?? "—"}
{latest && info?.latestPageUrl && (
<> · <a className="link is-neutral" href={info.latestPageUrl} target="_blank" rel="noreferrer">{t("updates.whats_new")}</a></>
)}
</dd>
</div>
<div><dt>{t("updates.checked")}</dt><dd>{checked}</dd></div>
</dl>
{info?.checkError && <div className="settings-section-error">{t("updates.check_error", { error: info.checkError })}</div>}
{errorText && <div className="settings-section-error">{errorText}</div>}
{installing && (
<p className="settings-card-note">{t("updates.installing", { version: info?.install.version ?? latest ?? "" })} {t(`updates.stage_${unreachable ? "restarting" : (["downloading", "pulling", "restarting"].includes(info?.install.message ?? "") ? info?.install.message : "starting") as "downloading" | "pulling" | "restarting" | "starting"}`)}</p>
)}
{!installing && status === "FAILED" && info?.install.version && (
<div className="settings-section-error">{t("updates.failed", { version: info.install.version })}{info.install.message ? `: ${info.install.message}` : ""}</div>
)}
{!installing && info?.available && (
<div className="settings-card-actions">
{canManage
? <Button variant="primary" disabled={busy || !info.updaterOnline} onClick={() => void run(installUpdate)}>{t("updates.install_version", { version: latest ?? "" })}</Button>
: <span className="settings-card-note">{t("updates.available_title", { version: latest ?? "" })}</span>}
{!info.updaterOnline && <span className="settings-card-note">{t("updates.updater_offline")}</span>}
</div>
)}
{!installing && info && !info.available && latest && !info.checkError && (
<p className="settings-card-note">{t("updates.up_to_date")}</p>
)}
</div>
);
}
@@ -0,0 +1,47 @@
import { api } from "../../api/client";
// Обновления установки (ADR-CHATBALLS-0049): состояние канала релизов и
// установки читается одним запросом; проверку и установку запускает только
// администратор установки. Путь без организации — это свойство инсталляции.
export type InstallStatus = "IDLE" | "REQUESTED" | "RUNNING" | "DONE" | "FAILED";
export type UpdateInfo = {
currentVersion: string;
latestVersion: string | null;
latestName: string;
latestNotes: string;
latestPublishedAt: string | null;
latestPageUrl: string;
available: boolean;
checkedAt: string | null;
checkError: string;
updaterOnline: boolean;
install: {
version: string | null;
status: InstallStatus;
// Стадия от сервиса обновления: starting · downloading · pulling · restarting · updated,
// либо текст ошибки.
message: string;
requestedAt: string | null;
updatedAt: string | null;
};
};
const BASE = "/api/v1/instance/update/";
export function fetchUpdate(): Promise<UpdateInfo> {
return api<{ update: UpdateInfo }>(BASE).then((payload) => payload.update);
}
export function checkUpdates(): Promise<UpdateInfo> {
return api<{ update: UpdateInfo }>(`${BASE}check/`, { method: "POST", body: "{}" }).then((payload) => payload.update);
}
export function installUpdate(): Promise<UpdateInfo> {
return api<{ update: UpdateInfo }>(`${BASE}install/`, { method: "POST", body: "{}" }).then((payload) => payload.update);
}
export function installInProgress(info: UpdateInfo | null): boolean {
return info?.install.status === "REQUESTED" || info?.install.status === "RUNNING";
}
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchUpdate, installInProgress, type UpdateInfo } from "./api";
// Состояние обновления с опросом, пока идёт установка. Во время перезапуска
// бэкенд не отвечает — это не ошибка, а стадия: запросы продолжают уходить,
// пока новая версия не ответит статусом DONE или FAILED.
const POLL_INTERVAL_MS = 3000;
export function useUpdateInfo(enabled: boolean) {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [unreachable, setUnreachable] = useState(false);
const wasInstalling = useRef(false);
const load = useCallback(async () => {
try {
const next = await fetchUpdate();
setInfo(next);
setUnreachable(false);
} catch {
// Нет прав, сеть моргнула или бэкенд перезапускается.
setUnreachable(true);
}
}, []);
useEffect(() => {
if (enabled) void load();
}, [enabled, load]);
const installing = installInProgress(info) || (unreachable && wasInstalling.current);
if (installInProgress(info)) wasInstalling.current = true;
useEffect(() => {
if (!enabled || !installing) return undefined;
const timer = window.setInterval(() => void load(), POLL_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [enabled, installing, load]);
return { info, setInfo, reload: load, installing, unreachable, finished: wasInstalling.current && !installing };
}
+36 -1
View File
@@ -157,7 +157,7 @@ export const en: Record<MessageKey, Message> = {
"admin.password_was_generated_shown_once": "The password was generated and is shown once — copy it now.",
"admin.people_count": "{count} people",
"admin.period": "Period",
"admin.png_jpeg_or_webp_up": "PNG, JPEG or WebP · up to 2 MB. Shown in the sidebar, the widget and emails.",
"admin.png_jpeg_or_webp_up": "PNG, JPEG, WebP or SVG · up to 2 MB. Shown in the sidebar, the widget and emails.",
"admin.position_groups": "Position and groups",
"admin.qr_code_setting_up_totp": "QR code for setting up TOTP",
"admin.recover_access": "Recover access",
@@ -1010,6 +1010,14 @@ export const en: Record<MessageKey, Message> = {
"portals.working": "working",
"portals.yaml_format_note": "A YAML file · format: articles: [{format}]",
"portals.yes_article_helped": "Yes, the article helped",
"organizations.add": "Add organization",
"organizations.create_title": "New organization",
"organizations.create_lead": "You will own the new organization and switch to it right away. The name, time zone and language can be changed later in Settings.",
"organizations.create_submit": "Create organization",
"organizations.creating": "Creating…",
"organizations.create_failed": "Could not create the organization",
"organizations.logo_failed": "The organization was created, but the logo failed to upload — add it in Settings.",
"organizations.name_placeholder": "For example, “Nord Atelier”",
"profile.accent_colour": "Accent colour",
"profile.address_unknown": "address unknown",
"profile.all_conversations": "All conversations",
@@ -1046,12 +1054,39 @@ export const en: Record<MessageKey, Message> = {
"profile.getting_started_progress": "{done} of {total}",
"profile.go_start_page": "Go to the start page",
"profile.switch_organization": "Switch organization",
"auth.choose_organization_title": "Choose an organization",
"auth.choose_organization_subtitle": "{name}, you belong to several organizations. Which one to start with?",
"auth.choose_organization_logout": "Sign out",
"auth.invitation_title": "Organization invitation",
"auth.invitation_accepting": "Accepting the invitation…",
"auth.invitation_not_accepted": "The invitation was not accepted",
"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",
"updates.available_title": "Version {version} is available",
"updates.whats_new": "What's new",
"updates.install": "Update",
"updates.install_version": "Update to {version}",
"updates.installing": "Updating to {version}:",
"updates.stage_starting": "starting",
"updates.stage_downloading": "downloading the release file",
"updates.stage_pulling": "pulling images",
"updates.stage_restarting": "restarting services",
"updates.done": "Version {version} is installed. Reload the page.",
"updates.failed": "Update to {version} failed",
"updates.confirm_title": "Update to {version}?",
"updates.confirm_text": "Services will restart and the application will be unavailable for about a minute. Conversations and data are kept.",
"updates.updater_offline": "The update service is not running, so updating from the interface is unavailable",
"updates.card_title": "Updates",
"updates.card_hint": "Installation version and updates from the release channel",
"updates.current_version": "Current version",
"updates.latest_version": "Latest version",
"updates.checked": "Check",
"updates.check": "Check now",
"updates.checked_at": "{time}",
"updates.never_checked": "Not checked yet",
"updates.up_to_date": "The latest version is installed",
"updates.check_error": "Could not check: {error}",
"profile.groups_list": "· groups: {groups}",
"profile.in_organization_since": "· in the organization since {date}",
"profile.interface_language": "Interface language",
+36 -1
View File
@@ -158,7 +158,7 @@ export const ru = {
"admin.password_was_generated_shown_once": "Пароль сгенерирован и показан один раз — скопируйте его сейчас.",
"admin.people_count": "{count} чел.",
"admin.period": "Период",
"admin.png_jpeg_or_webp_up": "PNG, JPEG или WebP · до 2 МБ. Виден в сайдбаре, виджете и письмах.",
"admin.png_jpeg_or_webp_up": "PNG, JPEG, WebP или SVG · до 2 МБ. Виден в сайдбаре, виджете и письмах.",
"admin.position_groups": "Должность и группы",
"admin.qr_code_setting_up_totp": "QR-код для подключения TOTP",
"admin.recover_access": "Восстановить доступ",
@@ -1011,6 +1011,14 @@ export const ru = {
"portals.working": "работает",
"portals.yaml_format_note": "Файл YAML · формат: articles: [{format}]",
"portals.yes_article_helped": "Да, статья полезна",
"organizations.add": "Добавить организацию",
"organizations.create_title": "Новая организация",
"organizations.create_lead": "Вы станете владельцем новой организации и сразу в неё переключитесь. Название, часовой пояс и язык потом можно поменять в «Настройках».",
"organizations.create_submit": "Создать организацию",
"organizations.creating": "Создаём…",
"organizations.create_failed": "Не удалось создать организацию",
"organizations.logo_failed": "Организация создана, но логотип загрузить не удалось — добавьте его в «Настройках».",
"organizations.name_placeholder": "Например, «Ателье Норд»",
"profile.accent_colour": "Акцентный цвет",
"profile.address_unknown": "адрес неизвестен",
"profile.all_conversations": "Все диалоги",
@@ -1047,12 +1055,39 @@ export const ru = {
"profile.getting_started_progress": "{done} из {total}",
"profile.go_start_page": "На главную",
"profile.switch_organization": "Переключить организацию",
"auth.choose_organization_title": "Выберите организацию",
"auth.choose_organization_subtitle": "{name}, вы состоите в нескольких организациях. С какой начать?",
"auth.choose_organization_logout": "Выйти",
"auth.invitation_title": "Приглашение в организацию",
"auth.invitation_accepting": "Принимаем приглашение…",
"auth.invitation_not_accepted": "Приглашение не принято",
"auth.invitation_invalid": "Приглашение недействительно или истекло",
"auth.join_subtitle": "Вас приглашают в «{organization}» как {email}. Укажите имя и задайте пароль для входа.",
"auth.join_accept": "Принять приглашение",
"updates.available_title": "Доступна версия {version}",
"updates.whats_new": "Что нового",
"updates.install": "Обновить",
"updates.install_version": "Обновить до {version}",
"updates.installing": "Обновление до {version}:",
"updates.stage_starting": "запуск",
"updates.stage_downloading": "загрузка файла релиза",
"updates.stage_pulling": "загрузка образов",
"updates.stage_restarting": "перезапуск сервисов",
"updates.done": "Установлена версия {version}. Обновите страницу.",
"updates.failed": "Обновление до {version} не удалось",
"updates.confirm_title": "Обновить до {version}?",
"updates.confirm_text": "Сервисы перезапустятся, приложение будет недоступно около минуты. Диалоги и данные сохранятся.",
"updates.updater_offline": "Сервис обновления не запущен, обновление из интерфейса недоступно",
"updates.card_title": "Обновления",
"updates.card_hint": "Версия установки и обновления с канала релизов",
"updates.current_version": "Текущая версия",
"updates.latest_version": "Последняя версия",
"updates.checked": "Проверка",
"updates.check": "Проверить",
"updates.checked_at": "{time}",
"updates.never_checked": "Ещё не проверялось",
"updates.up_to_date": "Установлена последняя версия",
"updates.check_error": "Не удалось проверить: {error}",
"profile.groups_list": "· группы: {groups}",
"profile.in_organization_since": "· в организации с {date}",
"profile.interface_language": "Язык интерфейса",
+6 -4
View File
@@ -1,7 +1,7 @@
import { notification as antToast } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import type { AppData, Employee, RouteKey, SessionUser } from "../types";
import type { AppData, AuthenticatedUser, Employee, RouteKey, SessionUser } from "../types";
import type { SettingsSectionKey } from "../features/settings/sections";
import type { PortalSettingsSectionKey } from "../features/support-portals/sections";
import { NotificationDrawer } from "../features/notifications/NotificationDrawer";
@@ -11,10 +11,11 @@ import { useChatScope } from "../features/chat/useChatScope";
import { isManager } from "../auth/access";
import { DemoInstallBanner } from "../features/settings/DemoInstallBanner";
import { Sidebar } from "./Sidebar";
import { UpdateBanner } from "../features/updates/UpdateBanner";
import { ShellRouteContent } from "./ShellRouteContent";
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentRoute, openKnowledgeRoute, openKnowledgeEditorRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout, onSwitchOrganization }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void;
openKnowledgeEditorRoute: (knowledgeId: number | null) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; openPortalSettingsRoute: (portalId: number, section?: PortalSettingsSectionKey) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onSwitchOrganization: (organizationPublicId: string) => void }) {
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentRoute, openKnowledgeRoute, openKnowledgeEditorRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout, onSwitchOrganization, onOrganizationCreated }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void;
openKnowledgeEditorRoute: (knowledgeId: number | null) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; openPortalSettingsRoute: (portalId: number, section?: PortalSettingsSectionKey) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onSwitchOrganization: (organizationPublicId: string) => void; onOrganizationCreated: (identity: AuthenticatedUser, organizationPublicId: string) => void }) {
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [notifOpen, setNotifOpen] = useState(false);
@@ -115,11 +116,12 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
<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} />}
<UpdateBanner enabled={user.isInstanceAdmin} />
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
«назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""} ${isAudit ? "audit-scroll" : ""} ${isPortals ? "portals-scroll" : ""} ${isKnowledge || isKnowledgeEditor ? "knowledge-scroll" : ""}`}>
<div key={route} className={`hub-page enter-surface ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isSettings ? "settings-page" : ""} ${isProfile ? "profile-page-shell" : ""} ${isContacts ? "contacts-page-shell" : ""} ${isAgents ? "agents-page-shell" : ""} ${isEmployees ? "employees-page-shell" : ""} ${isAudit ? "audit-page-shell" : ""} ${isKnowledge ? "knowledge-page-shell" : ""} ${isKnowledgeEditor ? "knowledge-editor-shell" : ""} ${isPortals ? "portals-page-shell" : ""}`}>
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} selectedEmployeeId={selectedEmployeeId} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} />
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} selectedEmployeeId={selectedEmployeeId} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} onOrganizationCreated={onOrganizationCreated} />
</div>
</main>
</div>
@@ -6,13 +6,14 @@ import { EmployeeDetailPage, EmployeesPage } from "../features/employees/Employe
import { ProfilePage } from "../features/profile/ProfilePage";
import { SettingsPage } from "../features/settings/SettingsPage";
import { AuditPage } from "../features/administration/AuditPage";
import { OrganizationCreatePage } from "../features/organizations/OrganizationCreatePage";
import { ChatPage } from "../features/chat/ChatPage";
import type { DialogScope } from "../features/conversations/ConversationWorkspace";
import type { ConversationCounters } from "../features/conversations/model";
import { SalesClientDetailPage } from "../features/sales/client-detail/SalesClientDetailPage";
import { SalesClientsPage } from "../features/sales/SalesClientsPage";
import { LoadingState } from "../shared/ui";
import type { AppData, Employee, RouteKey, SessionUser } from "../types";
import type { AppData, AuthenticatedUser, Employee, RouteKey, SessionUser } from "../types";
import type { SettingsSectionKey } from "../features/settings/sections";
import type { PortalSettingsSectionKey } from "../features/support-portals/sections";
import { hasCapability } from "../auth/access";
@@ -42,7 +43,7 @@ const SupportPortalDetailPage = lazy(() => import("../features/support-portals/S
(module) => ({ default: module.SupportPortalDetailPage }),
));
export function ShellRouteContent({ settingsSection, openSettings, chatScope, setChatScope, chatCounters, chatScopeSwitcher, route, data, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedChannelId, openChannel, selectedSupportPortalId, portalSettingsSection, openSupportPortal, openPortalSettings, openConversation, openEmployee, openAgent, openKnowledge, openKnowledgeEditor, onAgentLoaded, onChannelLoaded, reload, setRoute, user, onUserUpdated, onLogout, onOpenSidebar }: { settingsSection: SettingsSectionKey | null; openSettings: (section: SettingsSectionKey | null) => void; chatScope: DialogScope; setChatScope: (scope: DialogScope) => void; chatCounters: ConversationCounters | null; chatScopeSwitcher: boolean; route: RouteKey; data: AppData; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedChannelId: number | null; openChannel: (channelId: number) => void; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openSupportPortal: (portalId: number) => void; openPortalSettings: (portalId: number, section?: PortalSettingsSectionKey) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; openKnowledgeEditor: (knowledgeId: number | null) => void; onAgentLoaded: (name: string | null) => void; onChannelLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onOpenSidebar: () => void }) {
export function ShellRouteContent({ settingsSection, openSettings, chatScope, setChatScope, chatCounters, chatScopeSwitcher, route, data, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedChannelId, openChannel, selectedSupportPortalId, portalSettingsSection, openSupportPortal, openPortalSettings, openConversation, openEmployee, openAgent, openKnowledge, openKnowledgeEditor, onAgentLoaded, onChannelLoaded, reload, setRoute, user, onUserUpdated, onLogout, onOpenSidebar, onOrganizationCreated }: { settingsSection: SettingsSectionKey | null; openSettings: (section: SettingsSectionKey | null) => void; chatScope: DialogScope; setChatScope: (scope: DialogScope) => void; chatCounters: ConversationCounters | null; chatScopeSwitcher: boolean; route: RouteKey; data: AppData; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedChannelId: number | null; openChannel: (channelId: number) => void; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openSupportPortal: (portalId: number) => void; openPortalSettings: (portalId: number, section?: PortalSettingsSectionKey) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; openKnowledgeEditor: (knowledgeId: number | null) => void; onAgentLoaded: (name: string | null) => void; onChannelLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onOpenSidebar: () => void; onOrganizationCreated: (identity: AuthenticatedUser, organizationPublicId: string) => void }) {
return (
<>
{route === "employees" && <EmployeesPage groups={data.groups} openEmployee={openEmployee} setRoute={setRoute} user={user} />}
@@ -51,6 +52,7 @@ export function ShellRouteContent({ settingsSection, openSettings, chatScope, se
{route === "profile" && <ProfilePage user={user} onUserUpdated={onUserUpdated} reload={reload} onLogout={onLogout} onBack={() => setRoute("chat")} />}
{route === "settings" && <SettingsPage user={user} onUserUpdated={onUserUpdated} reload={reload} groups={data.groups} section={settingsSection} openSection={openSettings} setRoute={setRoute} />}
{route === "administrationAudit" && <AuditPage />}
{route === "organizationCreate" && <OrganizationCreatePage user={user} onCreated={onOrganizationCreated} onBack={() => setRoute("chat")} />}
{route === "salesClients" && <SalesClientsPage openClient={openClient} openIntegrations={() => openSettings("integrations")} />}
{route === "salesClientDetail" && <SalesClientDetailPage contactId={selectedClientId} canEdit={hasCapability(user, "customers.manage")} canMerge={user.role === "OWNER"} openConversation={openConversation} openClient={openClient} openClients={() => setRoute("salesClients")} />}
{route === "chat" && (
+36 -18
View File
@@ -5,7 +5,7 @@ import type { RouteKey, SessionUser } from "../types";
import type { SettingsSectionKey } from "../features/settings/sections";
import { useResizableWidth } from "../shared/useResizableWidth";
import { Icon, LogoIcon } from "../shared/icons";
import { defaultRoute, isManager } from "../auth/access";
import { canCreateOrganization, defaultRoute, isManager } from "../auth/access";
import type { DialogScope } from "../features/conversations/ConversationWorkspace";
import { agentColorOf, groupColorOf, type ConversationCounters } from "../features/conversations/model";
import { LaunchChecklist } from "./LaunchChecklist";
@@ -123,23 +123,41 @@ export function Sidebar({
placement="bottomLeft"
overlayClassName="app-dropdown is-wide"
menu={{
items: user.memberships.map((membership) => ({
key: membership.organizationPublicId,
label: (
<button
type="button"
className={membership.organizationPublicId === user.organizationPublicId ? "is-checked" : ""}
onClick={() => {
if (membership.organizationPublicId !== user.organizationPublicId) {
onSwitchOrganization(membership.organizationPublicId);
}
}}
>
<Icon name="building" size={15} />
{membership.organizationName || "Chatballs"}
</button>
),
})),
items: [
...user.memberships.map((membership) => ({
key: membership.organizationPublicId,
label: (
<button
type="button"
className={membership.organizationPublicId === user.organizationPublicId ? "is-checked" : ""}
onClick={() => {
if (membership.organizationPublicId !== user.organizationPublicId) {
onSwitchOrganization(membership.organizationPublicId);
}
}}
>
<Icon name="building" size={15} />
{membership.organizationName || "Chatballs"}
</button>
),
})),
// «Добавить организацию» — внизу списка, отделена чертой: ведёт на
// страницу создания, где человек становится владельцем новой.
...(canCreateOrganization(user)
? [
{ type: "divider" as const, key: "add-divider" },
{
key: "add-organization",
label: (
<button type="button" className="hub-brand-add" onClick={() => setRoute("organizationCreate")}>
<span className="hub-brand-add-icon"><Icon name="plus" size={11} strokeWidth={2.4} /></span>
{t("organizations.add")}
</button>
),
},
]
: []),
],
}}
>
<button className="hub-brand-switch" type="button" title={t("profile.switch_organization")}>
+46
View File
@@ -208,6 +208,42 @@
color: var(--n-4);
}
/* «Добавить организацию» в переключателе (A1): такая же строка, как у
организаций, только на месте значка — пунктирная плитка с плюсом, как у
«пустого» слота. Ничего залитого: строка читается как действие, а не как
ещё одна организация, и не спорит с отмеченной текущей. */
.app-dropdown .ant-dropdown-menu-item button.hub-brand-add {
color: var(--n-3);
}
.app-dropdown .ant-dropdown-menu-item button.hub-brand-add:hover {
color: var(--primary-text);
}
/* Селектор длиннее общего «button span { display: block }» из menu.css:
иначе плюс прижимается к левому верхнему углу плитки. */
.app-dropdown .ant-dropdown-menu-item button .hub-brand-add-icon {
width: 17px;
height: 17px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
color: var(--n-4);
border: 1.5px dashed var(--n-6);
border-radius: 6px;
transition: color 0.15s ease, border-color 0.15s ease;
}
.hub-brand-add-icon svg {
display: block;
}
.app-dropdown .ant-dropdown-menu-item button.hub-brand-add:hover .hub-brand-add-icon {
color: var(--primary-text);
border-color: var(--primary);
}
/* Логотип — сам по себе, без подложки: цвет текста темы. */
.hub-brand-mark {
width: 28px;
@@ -779,6 +815,16 @@
color: var(--success-text);
}
.demo-install-banner.is-failed {
background: var(--error-bg);
color: var(--error-text);
}
.update-banner a.link {
flex: none;
font-size: 13px;
}
.demo-install-banner > svg {
flex: none;
}
+10
View File
@@ -14,6 +14,16 @@ const empty = {
settingsSection: null,
};
describe("organization create route", () => {
// Страница создания живёт вне организации: адрес без uuid, а сборка адреса
// не подставляет префикс текущей организации.
it("parses and builds /organizations/new", () => {
expect(routeFromPath("/organizations/new")).toEqual({ route: "organizationCreate", ...empty });
expect(routeFromPath("/organizations/new/")).toEqual({ route: "organizationCreate", ...empty });
expect(pathFromRoute("organizationCreate", null, "123e4567-e89b-12d3-a456-426614174000")).toBe("/organizations/new");
});
});
describe("sales detail routes", () => {
it("parses a client detail URL", () => {
expect(routeFromPath("/departments/sales/clients/15")).toEqual({ route: "salesClientDetail", ...empty, clientId: 15 });
+6
View File
@@ -21,6 +21,11 @@ export type RouteState = {
export function routeFromPath(pathname: string, search = ""): RouteState {
const normalized = pathname.replace(/\/+$/, "") || "/";
// Страница создания организации живёт вне организации: у неё ещё нет
// адреса, а человек попадает сюда из переключателя любой из своих (A1).
if (normalized === "/organizations/new") {
return { route: "organizationCreate", organizationPublicId: null, employeeId: null, agentId: null, knowledgeId: null, clientId: null, channelId: null, supportPortalId: null, portalSettingsSection: null, settingsSection: null };
}
const match = normalized.match(/^\/organizations\/([0-9a-f-]{36})(\/.*)?$/i);
const organizationPublicId = match?.[1] ?? null;
const path = match ? match[2] || "/" : normalized;
@@ -114,6 +119,7 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
export function pathFromRoute(route: RouteKey, entityId: number | string | null = null, organizationPublicId: string | null = null): string {
const prefix = organizationPublicId ? `/organizations/${organizationPublicId}` : "";
if (route === "organizationCreate") return "/organizations/new";
if (route === "salesClients") return `${prefix}/contacts`;
if (route === "salesClientDetail") return entityId ? `${prefix}/contacts/${entityId}` : `${prefix}/contacts`;
if (route === "chat") return `${prefix}/chat`;
+1
View File
@@ -22,4 +22,5 @@ export const routes: Record<RouteKey, string> = {
knowledgeCategories: t("shared.knowledge_categories"),
knowledgeImport: t("shared.knowledge_import"),
aiUsage: t("shared.ai_usage"),
organizationCreate: t("organizations.create_title"),
};
+92 -27
View File
@@ -1,3 +1,9 @@
/* Поля формы приложения. Геометрия — дизайн-базлайн v2 (кадры G3, N1/N6, P1):
подпись 12.5/600 цветом --n-3, поле 36px на подложке --n-10 рамкой --n-7,
текст 13.5px. Скин был общим только на бумаге: здесь стояли 38px на
--surface-card, а базлайн добирали поверх в профиле, настройках и модалке
создания агента — три копии одного поля. Копий больше нет, скин один и
живёт здесь. line-height задан явно: внутри antd Modal свой reset. */
.readonly-field {
display: block;
}
@@ -6,24 +12,26 @@
grid-column: span 2;
}
.readonly-field span {
.readonly-field > span:first-child {
display: block;
margin-bottom: 6px;
color: var(--n-4);
font-size: 12px;
font-weight: 500;
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
line-height: normal;
}
.readonly-field input {
width: 100%;
height: 38px;
height: 36px;
padding: 0 12px;
color: var(--n-2);
background: var(--surface-card);
color: var(--n-1);
background: var(--n-10);
border: 1px solid var(--n-7);
border-radius: 8px;
outline: none;
font-size: 13px;
font-size: 13.5px;
line-height: normal;
}
.form-field.is-editable:focus-within input,
@@ -31,7 +39,7 @@
.form-field.is-editable:focus-within textarea,
.form-field.is-editable textarea:focus,
.select-like:focus-within select,
.select-like select:focus {
.select-like .select-box:focus-within {
border-color: var(--primary);
}
@@ -57,45 +65,102 @@
.form-field.is-readonly input,
.readonly-field input:disabled {
color: var(--n-4);
background: var(--n-10);
border-color: var(--n-8);
}
.form-field.is-editable input {
background: var(--surface-card);
}
.select-like {
position: relative;
}
.select-like input,
.select-like select {
width: 100%;
height: 38px;
padding-right: 32px;
height: 36px;
padding: 0 32px 0 12px;
cursor: pointer;
}
.select-like select {
padding-left: 12px;
color: var(--n-2);
background: var(--surface-card);
color: var(--n-1);
background: var(--n-10);
border: 1px solid var(--n-7);
border-radius: 8px;
outline: none;
appearance: none;
font-size: 13px;
font-size: 13.5px;
line-height: normal;
}
.select-like svg {
/* Шеврон по центру поля: 36px бокса минус 14px иконки. Отступ справа — те же
12px внутреннего поля плюс 1px рамки, что и у шеврона внутри `.select-box`. */
.select-like > svg {
position: absolute;
right: 11px;
bottom: 12px;
right: 13px;
bottom: 11px;
color: var(--n-4);
pointer-events: none;
}
/* Бокс селекта — для метки перед значением (точка группы на карточке агента) и
для режима без прав правки. Рамку держит бокс, селект внутри без своей. */
.select-like .select-box {
display: flex;
align-items: center;
gap: 7px;
height: 36px;
padding: 0 12px;
color: var(--n-1);
background: var(--n-10);
border: 1px solid var(--n-7);
border-radius: 8px;
font-size: 13.5px;
}
.select-like .select-box select {
flex: 1;
height: 34px;
padding: 0;
color: inherit;
background: transparent;
border: 0;
font-size: inherit;
}
.select-like .select-box > svg {
flex: none;
color: var(--n-4);
pointer-events: none;
}
.select-like .select-value {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.select-like.is-invalid select,
.select-like.is-invalid .select-box {
color: var(--error-text);
border-color: var(--error-border);
}
/* Узкий экран (мобильные кадры профиля и настроек): поле под палец — 44px и
текст 15px. Это тоже лежало копиями в профиле и настройках. */
@media (max-width: 900px) {
.readonly-field input,
.select-like select,
.select-like .select-box {
height: 44px;
font-size: 15px;
}
.select-like .select-box select {
height: 42px;
}
.select-like > svg {
bottom: 15px;
}
}
/* Единый переключатель приложения. Раньше в проекте было четыре его копии
(.ui-switch, .totp-switch, .detail-totp-switch, .channel-switch) — остался
один скин, размер при необходимости задаётся модификатором. */
+33 -5
View File
@@ -44,14 +44,42 @@ export function FormField({
);
}
export function SelectField({ disabled = false, label, value, onChange, options }: { disabled?: boolean; label: string; value: string; onChange: (value: string) => void; options: Array<[string, string]> }) {
return (
<label className="readonly-field select-like">
<span>{label}</span>
type SelectFieldProps = {
/** Метка перед значением: цветная точка группы на карточке агента. */
adornment?: ReactNode;
disabled?: boolean;
invalid?: boolean;
label: string;
onChange: (value: string) => void;
options: Array<[string, string]>;
/** Нет прав на правку: вместо селекта значение показывается текстом. */
readOnly?: boolean;
/** Текст для режима без прав, когда значения нет в списке: список вариантов
* грузят только тем, кто может править. */
readOnlyText?: string;
value: string;
wide?: boolean;
};
/** Селект приложения — один на всё: подпись, бокс поля и шеврон. Точку перед
* значением и режим без прав правки держит `.select-box`: рамка тогда на нём,
* а сам селект внутри без рамки и фона. */
export function SelectField({ adornment, disabled = false, invalid = false, label, onChange, options, readOnly = false, readOnlyText, value, wide = false }: SelectFieldProps) {
const className = ["readonly-field", "select-like", invalid ? "is-invalid" : "", readOnly ? "is-readonly" : "", wide ? "wide" : ""].filter(Boolean).join(" ");
const control = readOnly
? <span className="select-value">{readOnlyText ?? options.find(([optionValue]) => optionValue === value)?.[1] ?? ""}</span>
: (
<select disabled={disabled} value={value} onChange={(event) => onChange(event.target.value)}>
{options.map(([optionValue, labelText]) => <option value={optionValue} key={optionValue}>{labelText}</option>)}
</select>
<Icon name="chevron" size={14} />
);
const chevron = <Icon name="chevron" size={14} strokeWidth={2.2} />;
const boxed = Boolean(adornment) || readOnly;
return (
<label className={className}>
<span>{label}</span>
{boxed ? <span className="select-box">{adornment}{control}{chevron}</span> : <>{control}{chevron}</>}
</label>
);
}
+1
View File
@@ -24,6 +24,7 @@
@import "./features/ai/knowledge/styles-pages.css";
@import "./features/integrations/styles.css";
@import "./features/administration/styles.css";
@import "./features/organizations/styles.css";
@import "./features/settings/styles.css";
@import "./features/notifications/styles.css";
@import "./shared/state.css";
+1 -1
View File
@@ -124,7 +124,7 @@ export type EmployeeAuditEvent = {
createdAt: string;
};
export type RouteKey = "administrationAudit" | "employeeDetail" | "employees" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "chat" | "supportPortals" | "supportPortalDetail" | "supportPortalSettings" | "agents" | "agentDetail" | "knowledge" | "knowledgeDetail" | "knowledgeCreate" | "knowledgeEdit" | "knowledgeCategories" | "knowledgeImport" | "aiUsage";
export type RouteKey = "administrationAudit" | "employeeDetail" | "employees" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "chat" | "supportPortals" | "supportPortalDetail" | "supportPortalSettings" | "agents" | "agentDetail" | "knowledge" | "knowledgeDetail" | "knowledgeCreate" | "knowledgeEdit" | "knowledgeCategories" | "knowledgeImport" | "aiUsage" | "organizationCreate";
export type AppData = {
groups: EmployeeGroup[];
+7
View File
@@ -116,6 +116,13 @@ services:
- ./apps/backend:/app/apps/backend
- ./data/media:/app/apps/backend/media
# Dev: образ сервиса обновления собирается локально; сам сервис в dev
# бесполезен (стек поднят из исходников), но должен собираться и стартовать.
updater:
build:
context: .
dockerfile: deploy/docker/updater.Dockerfile
# Dev: frontend-сервис превращается в Vite dev-сервер internal-ui (HMR).
frontend:
build:
+23
View File
@@ -115,6 +115,9 @@ services:
# владельца из образа (hub), поэтому загрузки работают и на Linux, где
# bind-mount достался бы контейнеру как root:root и падал с EACCES.
- chatballs-media:/app/apps/backend/media
# Обмен с сервисом updater: сюда кладётся запрос на обновление, отсюда
# читается его статус. Доступа к Docker у приложения нет и не будет.
- chatballs-updates:/run/chatballs/updates
depends_on:
init:
condition: service_completed_successfully
@@ -214,6 +217,24 @@ services:
backend-app:
condition: service_healthy
# Обновление по кнопке из интерфейса (ADR-CHATBALLS-0049). Единственный
# сервис с доступом к docker.sock: забирает запрос из тома chatballs-updates,
# проверяет, что это официальный релиз (адрес со страницы релизов, образы по
# digest), и запускает `docker compose pull && up` отдельным контейнером вне
# проекта. Без сокета (rootless, podman) просто спит — кнопка в интерфейсе
# тогда сообщает, что обновление из интерфейса недоступно.
updater:
image: ${CHATBALLS_UPDATER_IMAGE:-chatballs-updater:dev}
restart: unless-stopped
environment:
CHATBALLS_UPDATE_REPO: ${CHATBALLS_UPDATE_REPO:-dartdavros/chatballs}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- chatballs-updates:/run/chatballs/updates
depends_on:
backend-app:
condition: service_healthy
# Frontend: один nginx-образ со static-сборкой internal-ui и web-chat,
# внутренняя маршрутизация (/api/, /ws/, /chat-widget.js,
# /chat/, /calls/) — в deploy/nginx/frontend.production.conf. Не публикует
@@ -332,3 +353,5 @@ volumes:
chatballs-media:
chatballs-caddy-data:
chatballs-caddy-config:
# Обмен приложения с сервисом updater: запрос и статус обновления.
chatballs-updates:
+11
View File
@@ -0,0 +1,11 @@
# Сервис updater: обновление установки по кнопке из интерфейса (ADR-CHATBALLS-0049).
# Официальный образ docker CLI с плагином compose плюс два сценария; больше в
# нём ничего нет. Базовый образ закрепляется по digest при сборке релиза.
ARG CHATBALLS_UPDATER_BASE_IMAGE=docker:27-cli
FROM ${CHATBALLS_UPDATER_BASE_IMAGE}
COPY deploy/updater/chatballs-updater.sh /usr/local/bin/chatballs-updater.sh
COPY deploy/updater/chatballs-updater-apply.sh /usr/local/bin/chatballs-updater-apply.sh
RUN chmod 0755 /usr/local/bin/chatballs-updater.sh /usr/local/bin/chatballs-updater-apply.sh
ENTRYPOINT ["/bin/sh", "/usr/local/bin/chatballs-updater.sh"]
+92
View File
@@ -0,0 +1,92 @@
#!/bin/sh
# Помощник обновления: скачать релизный compose.yaml, проверить, применить.
# Запускается сервисом updater отдельным контейнером вне проекта compose, чтобы
# пережить пересоздание самого updater'а. Пишет статус в том chatballs-updates.
set -u
VERSION="${1:?version}"
COMPOSE_URL="${2:?compose url}"
DIR="${CHATBALLS_UPDATES_DIR:-/run/chatballs/updates}"
REPO="${CHATBALLS_UPDATE_REPO:?}"
PROJECT="${CHATBALLS_PROJECT:?}"
WORKDIR="${CHATBALLS_WORKDIR:-}"
VOLUME="${CHATBALLS_UPDATES_VOLUME:?}"
SELF_IMAGE="${CHATBALLS_UPDATER_IMAGE:?}"
STATUS="$DIR/status.json"
LOG="$DIR/apply.log"
FILE="$DIR/compose.$VERSION.yaml"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) apply $VERSION: $*" | tee -a "$LOG"; }
write_status() {
message=$(printf '%s' "$2" | tr '\n' ' ' | sed 's/"/\\"/g' | cut -c1-900)
printf '{"status":"%s","version":"%s","message":"%s","at":"%s"}\n' \
"$1" "$VERSION" "$message" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS.tmp"
mv "$STATUS.tmp" "$STATUS"
}
fail() {
log "ОШИБКА: $1"
write_status failed "$1"
exit 1
}
: > "$LOG"
write_status running "downloading"
log "скачивание $COMPOSE_URL"
if ! wget -q -T 60 -O "$FILE" "$COMPOSE_URL"; then
fail "compose.yaml не скачался"
fi
# Файл релиза обязан ссылаться на образы только по digest и только из
# реестра продукта (свои образы) или из закреплённых сторонних (redis, coturn).
if grep -E '^\s*image:' "$FILE" | grep -vqE '@sha256:[0-9a-f]{64}'; then
fail "в compose.yaml есть образ без digest"
fi
if grep -E '^\s*image:\s*ghcr\.io/' "$FILE" | grep -vq "ghcr.io/$REPO/"; then
fail "в compose.yaml есть образ из чужого реестра"
fi
compose() {
if [ -n "$WORKDIR" ]; then
docker compose -p "$PROJECT" --project-directory "$WORKDIR" -f "$FILE" "$@"
else
docker compose -p "$PROJECT" -f "$FILE" "$@"
fi
}
# Профиль звонков включён, если coturn уже работает в проекте.
PROFILE_ARGS=""
if docker ps -q --filter "label=com.docker.compose.project=$PROJECT" --filter "label=com.docker.compose.service=coturn" | grep -q .; then
PROFILE_ARGS="--profile calls"
fi
if ! compose $PROFILE_ARGS config -q >>"$LOG" 2>&1; then
fail "compose.yaml не прошёл проверку"
fi
write_status running "pulling"
log "загрузка образов"
if ! compose $PROFILE_ARGS pull >>"$LOG" 2>&1; then
fail "не удалось загрузить образы: $(tail -n 3 "$LOG" | tr '\n' ' ')"
fi
write_status running "restarting"
log "перезапуск сервисов"
if ! compose $PROFILE_ARGS up -d --wait >>"$LOG" 2>&1; then
fail "стек не поднялся: $(tail -n 3 "$LOG" | tr '\n' ' ')"
fi
# Файл на хосте должен соответствовать работающей версии: иначе следующий
# ручной `docker compose up` откатил бы установку.
if [ -n "$WORKDIR" ]; then
if docker run --rm -v "$WORKDIR:/host" -v "$VOLUME:/updates" "$SELF_IMAGE" \
sh -c "cp /updates/compose.$VERSION.yaml /host/compose.yaml" >>"$LOG" 2>&1; then
log "compose.yaml в $WORKDIR обновлён"
else
log "предупреждение: не удалось обновить compose.yaml в $WORKDIR"
fi
fi
write_status done "updated"
log "готово"
+100
View File
@@ -0,0 +1,100 @@
#!/bin/sh
# Сервис updater: обновление установки по кнопке из интерфейса (ADR-CHATBALLS-0049).
#
# Приложение не имеет доступа к Docker и не может перезапустить само себя:
# оно кладёт запрос в том chatballs-updates, а этот сервис — единственный,
# у кого смонтирован docker.sock, — забирает запрос, проверяет его и запускает
# `docker compose pull && up` для всего проекта.
#
# Запрос принимается только на официальный релиз: версия проверяется по
# формату, compose.yaml скачивается только со страницы релизов репозитория
# продукта, а все образы в нём обязаны быть закреплены по digest. Больше
# ничего этот сервис делать не умеет, что бы ни лежало в запросе.
#
# Сам сервис тоже входит в проект и пересоздаётся при обновлении, поэтому
# работу выполняет отдельный контейнер вне проекта (chatballs-updater-apply.sh):
# он переживает пересоздание и дописывает статус в том до конца.
set -eu
DIR="${CHATBALLS_UPDATES_DIR:-/run/chatballs/updates}"
REPO="${CHATBALLS_UPDATE_REPO:-dartdavros/chatballs}"
POLL_SECONDS="${CHATBALLS_UPDATER_POLL_SECONDS:-5}"
SOCKET="/var/run/docker.sock"
REQUEST="$DIR/request.json"
STATUS="$DIR/status.json"
HEARTBEAT="$DIR/heartbeat"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) updater: $*"; }
write_status() {
# $1 status, $2 version, $3 message
message=$(printf '%s' "$3" | tr '\n' ' ' | sed 's/"/\\"/g' | cut -c1-900)
printf '{"status":"%s","version":"%s","message":"%s","at":"%s"}\n' \
"$1" "$2" "$message" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS.tmp"
mv "$STATUS.tmp" "$STATUS"
}
json_field() {
# Значение строкового поля $2 из JSON-файла $1 (одна строка, без вложенности).
sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1
}
mkdir -p "$DIR"
chmod 777 "$DIR"
if [ ! -S "$SOCKET" ]; then
log "docker.sock не смонтирован — обновление из интерфейса недоступно"
# Без сокета сервис бесполезен, но и падать в перезапусках незачем.
while true; do sleep 3600; done
fi
# Имя проекта и рабочий каталог берутся из меток собственного контейнера:
# именно так их видит compose на хосте.
SELF_ID="$(cat /etc/hostname)"
PROJECT="$(docker inspect -f '{{ index .Config.Labels "com.docker.compose.project" }}' "$SELF_ID" 2>/dev/null || true)"
WORKDIR="$(docker inspect -f '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' "$SELF_ID" 2>/dev/null || true)"
UPDATES_VOLUME="$(docker inspect -f '{{ range .Mounts }}{{ if eq .Destination "'"$DIR"'" }}{{ .Name }}{{ end }}{{ end }}' "$SELF_ID" 2>/dev/null || true)"
SELF_IMAGE="$(docker inspect -f '{{ .Config.Image }}' "$SELF_ID" 2>/dev/null || true)"
if [ -z "$PROJECT" ] || [ -z "$UPDATES_VOLUME" ] || [ -z "$SELF_IMAGE" ]; then
log "не удалось определить проект compose по меткам контейнера (project=$PROJECT volume=$UPDATES_VOLUME image=$SELF_IMAGE)"
while true; do sleep 3600; done
fi
log "готов: проект $PROJECT, каталог ${WORKDIR:-?}, том $UPDATES_VOLUME"
while true; do
touch "$HEARTBEAT"
if [ -f "$REQUEST" ]; then
version="$(json_field "$REQUEST" version)"
compose_url="$(json_field "$REQUEST" compose_url)"
rm -f "$REQUEST"
case "$version" in
''|*[!0-9A-Za-z.-]*) log "отклонён запрос с недопустимой версией: $version"; write_status failed "$version" "invalid version"; sleep "$POLL_SECONDS"; continue ;;
esac
expected="https://github.com/$REPO/releases/download/v$version/compose.yaml"
if [ "$compose_url" != "$expected" ]; then
log "отклонён запрос: адрес compose.yaml не со страницы релизов ($compose_url)"
write_status failed "$version" "compose.yaml address is not the release page"
sleep "$POLL_SECONDS"; continue
fi
if docker ps -q --filter "name=^chatballs-updater-apply$" | grep -q .; then
log "установка уже идёт — запрос $version пропущен"
sleep "$POLL_SECONDS"; continue
fi
log "установка $version: запуск"
write_status running "$version" "starting"
# Помощник вне проекта: свой образ, docker.sock и тот же том; переживает
# пересоздание этого сервиса и дописывает статус до конца.
if ! docker run -d --rm --name chatballs-updater-apply \
-e CHATBALLS_UPDATE_REPO="$REPO" \
-e CHATBALLS_PROJECT="$PROJECT" \
-e CHATBALLS_WORKDIR="$WORKDIR" \
-e CHATBALLS_UPDATES_VOLUME="$UPDATES_VOLUME" \
-e CHATBALLS_UPDATER_IMAGE="$SELF_IMAGE" \
-v "$SOCKET:$SOCKET" \
-v "$UPDATES_VOLUME:$DIR" \
"$SELF_IMAGE" chatballs-updater-apply.sh "$version" "$expected" >/dev/null 2>"$DIR/apply.err"; then
write_status failed "$version" "could not start helper: $(cat "$DIR/apply.err" 2>/dev/null | tail -c 400)"
fi
fi
sleep "$POLL_SECONDS"
done
@@ -5,9 +5,10 @@
.readonly-field > span {
display: block;
margin-bottom: 6px;
color: var(--text-tertiary);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
font-size: 12.5px;
font-weight: 600;
line-height: normal;
}
.readonly-field input,
@@ -23,13 +24,22 @@
font-size: 13px;
}
.readonly-field input,
.select-like select,
.search-control {
height: 38px;
padding: 0 12px;
}
/* Поле формы — дизайн-базлайн v2 (кадры G3, N1/N6, P1): 36px на подложке
--n-10, текст 13.5px. Один скин на всё приложение. */
.readonly-field input,
.select-like select {
height: 36px;
padding: 0 12px;
color: var(--n-1);
background: var(--n-10);
font-size: 13.5px;
}
.readonly-field textarea {
min-height: 92px;
padding: 10px 12px;
@@ -40,6 +50,7 @@
.form-field.is-editable:focus-within input,
.form-field.is-editable:focus-within textarea,
.select-like:focus-within select,
.select-like .select-box:focus-within,
.search-control:focus-within {
border-color: var(--primary);
}
@@ -71,14 +82,67 @@
appearance: none;
}
.select-like .select-chevron {
.select-like > .select-chevron {
position: absolute;
right: 11px;
bottom: 12px;
right: 13px;
bottom: 11px;
color: var(--text-tertiary);
pointer-events: none;
}
/* Бокс селекта — для метки перед значением (точка группы на карточке агента) и
для режима без прав правки. Рамку держит бокс, селект внутри без своей. */
.select-like .select-box {
display: flex;
align-items: center;
gap: 7px;
height: 36px;
padding: 0 12px;
color: var(--n-1);
background: var(--n-10);
border: 1px solid var(--border-input);
border-radius: var(--radius-md);
font-size: 13.5px;
}
.select-like .select-box select {
flex: 1;
height: 34px;
padding: 0;
color: inherit;
background: transparent;
border: 0;
font-size: inherit;
}
.select-like .select-box > .select-chevron {
flex: none;
color: var(--text-tertiary);
pointer-events: none;
}
/* Пример метки перед значением: цветная точка группы. */
.select-like .select-dot {
width: 8px;
height: 8px;
flex: none;
border-radius: 50%;
}
.select-like .select-value {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.select-like.is-invalid select,
.select-like.is-invalid .select-box {
color: var(--error-text);
border-color: var(--error-border);
}
.search-control {
display: flex;
align-items: center;
+3
View File
@@ -95,6 +95,9 @@
<div class="ds-specimen-header"><strong>Select & textarea</strong><code>SelectField · TextAreaField</code></div>
<div class="ds-specimen-body is-column">
<label class="readonly-field select-like"><span>Статус</span><select aria-label="Статус"><option>Активен</option><option>Неактивен</option></select><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></label>
<label class="readonly-field select-like"><span>Группа</span><span class="select-box"><i class="select-dot" style="background:#4096ff;"></i><select aria-label="Группа"><option>Поддержка</option><option>Продажи</option></select><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></span></label>
<label class="readonly-field select-like is-invalid"><span>Провайдер</span><select aria-label="Провайдер"><option>Не выбран</option></select><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></label>
<label class="readonly-field select-like is-readonly"><span>Группа без прав правки</span><span class="select-box"><span class="select-value">Без группы</span><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></span></label>
<label class="readonly-field form-field is-editable"><span>Описание</span><textarea aria-label="Описание">Канал обработки обращений клиентов.</textarea></label>
</div>
</article>
+1
View File
@@ -30,6 +30,7 @@ IMAGE_KEYS = (
"CHATBALLS_REDIS_IMAGE",
"CHATBALLS_GATEWAY_IMAGE",
"CHATBALLS_COTURN_IMAGE",
"CHATBALLS_UPDATER_IMAGE",
)
DIGEST_RE = re.compile(r"@sha256:[0-9a-fA-F]{64}$")
+9 -6
View File
@@ -6,8 +6,9 @@
стек падал на первом старте, уже у человека.
Эти тесты держат свойство, а не текущий текст файла: в production-манифесте нет
ни одного bind-mount (кроме сертификатов TURN у опционального профиля calls), а
все ссылки на образы поддаются закреплению по digest.
ни одного bind-mount (кроме сертификатов TURN у опционального профиля calls и
Docker-сокета у сервиса обновлений), а все ссылки на образы поддаются
закреплению по digest.
"""
from __future__ import annotations
@@ -23,9 +24,10 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
COMPOSE = REPO_ROOT / "compose.yaml"
PIN_SCRIPT = REPO_ROOT / "scripts" / "pin-release-compose.py"
# Единственное исключение: сертификат TURN-хоста кладёт на хост renewal-хук,
# и только при включённом профиле calls.
BIND_MOUNT_EXCEPTIONS = {"coturn"}
# Исключения: сертификат TURN-хоста кладёт на хост renewal-хук (только при
# включённом профиле calls), а сервис обновлений управляет стеком через
# Docker-сокет хоста — это его назначение (ADR-CHATBALLS-0049).
BIND_MOUNT_EXCEPTIONS = {"coturn", "updater"}
DIGEST = "sha256:" + "a" * 64
IMAGE_KEYS = (
@@ -35,6 +37,7 @@ IMAGE_KEYS = (
"CHATBALLS_REDIS_IMAGE",
"CHATBALLS_GATEWAY_IMAGE",
"CHATBALLS_COTURN_IMAGE",
"CHATBALLS_UPDATER_IMAGE",
)
@@ -82,7 +85,7 @@ def test_named_volumes_are_declared() -> None:
def test_every_image_reference_can_be_pinned(tmp_path: Path) -> None:
"""Все шесть ключей образов присутствуют и закрепляются по digest.
"""Все семь ключей образов присутствуют и закрепляются по digest.
Если из манифеста уйдёт (или переименуется) хоть один ключ, релизный
compose.yaml уедет с плавающим тегом — а найдётся это уже у человека.
+119
View File
@@ -350,6 +350,34 @@ test("гость по ссылке-приглашению задаёт имя и
expect(registered[0]).toContain("guest-token");
});
test("администратор установки видит баннер о новой версии и запускает обновление", async ({ page }) => {
await mockSession(page, OWNER_IDENTITY);
await mockInstance(page);
await mockEmployees(page);
const update = {
currentVersion: "1.4.0", latestVersion: "1.5.0", latestName: "v1.5.0", latestNotes: "", latestPublishedAt: null,
latestPageUrl: "https://github.com/dartdavros/chatballs/releases/tag/v1.5.0", available: true, checkedAt: null,
checkError: "", updaterOnline: true, install: { version: null, status: "IDLE", message: "", requestedAt: null, updatedAt: null },
};
await page.route("**/api/v1/instance/update/", (route) => route.fulfill({ json: { update } }));
const installs: string[] = [];
await page.route("**/api/v1/instance/update/install/", (route) => {
installs.push(route.request().method());
return route.fulfill({ status: 202, json: { update: { ...update, install: { ...update.install, version: "1.5.0", status: "REQUESTED" } } } });
});
await page.goto(`/organizations/${ORGANIZATION_PUBLIC_ID}/chat`);
const banner = page.locator(".update-banner");
await expect(banner).toContainText("Доступна версия 1.5.0");
await banner.getByRole("button", { name: "Обновить", exact: true }).click();
await expect(page.getByText("Обновить до 1.5.0?")).toBeVisible();
await page.locator(".decision-dialog").getByRole("button", { name: "Обновить", exact: true }).click();
await expect.poll(() => installs).toEqual(["POST"]);
await expect(page.locator(".update-banner")).toContainText("Обновление до 1.5.0");
});
test("с несколькими организациями вход открывает первую, переключатель ведёт во вторую", async ({ page }) => {
const secondMembership = membershipFor("OWNER", {
id: 3,
@@ -379,6 +407,97 @@ test("с несколькими организациями вход открыв
await expect(managerNav(page)).toHaveCount(MANAGER_NAV.length);
});
test("после входа с несколькими организациями показывается выбор, ссылка на организацию его минует", async ({ page }) => {
const secondMembership = membershipFor("OWNER", {
id: 3,
organizationPublicId: SECOND_ORGANIZATION_PUBLIC_ID,
organization: "second",
organizationName: "Вторая организация",
positionTitle: "Директор",
});
const identity = identityFor("OWNER", [membershipFor("OWNER"), secondMembership]);
await login(page, identity);
// Экран выбора: обе организации строками с ролью, приложение ещё не открыто.
await expect(page.getByRole("heading", { name: "Выберите организацию" })).toBeVisible();
const list = page.locator(".auth-org-list");
await expect(list.getByRole("button")).toHaveCount(2);
await expect(list.getByRole("button", { name: /Вторая организация/ })).toContainText("Директор");
await expect(managerNav(page)).toHaveCount(0);
await list.getByRole("button", { name: /Вторая организация/ }).click();
await expect(page).toHaveURL(new RegExp(`/organizations/${SECOND_ORGANIZATION_PUBLIC_ID}/chat`));
await expect(page.locator(".hub-brand-switch span")).toHaveText("Вторая организация");
await expect(managerNav(page)).toHaveCount(MANAGER_NAV.length);
// Прямая ссылка на организацию: вход ведёт сразу в неё (на стартовый
// экран, как и всегда после входа), без выбора.
await mockSession(page, null);
await page.goto(`/organizations/${ORGANIZATION_PUBLIC_ID}/employees`);
await page.getByPlaceholder("you@domain.ru").fill("user@example.com");
await page.getByPlaceholder("Пароль").fill("Password-123");
await page.getByRole("button", { name: "Войти" }).click();
await expect(page).toHaveURL(new RegExp(`/organizations/${ORGANIZATION_PUBLIC_ID}/chat`));
await expect(page.locator(".hub-brand-switch span")).toHaveText("Ателье Норд");
await expect(page.getByRole("heading", { name: "Выберите организацию" })).toHaveCount(0);
});
test("владелец добавляет организацию из переключателя и сразу в неё попадает", async ({ page }) => {
const NEW_ORGANIZATION_PUBLIC_ID = "323e4567-e89b-12d3-a456-426614174000";
await mockInstance(page);
await mockEmployees(page);
await mockSession(page, OWNER_IDENTITY);
await page.route("**/api/v1/organizations/options/", (route) =>
route.fulfill({ json: { timezones: ["Europe/Moscow", "Europe/Berlin"], languages: [{ code: "ru", label: "Русский" }, { code: "en", label: "English" }], currencies: ["RUB"] } }),
);
let created: Record<string, unknown> | null = null;
await page.route("**/api/v1/organizations/", (route) => {
created = route.request().postDataJSON();
const membership = membershipFor("OWNER", {
id: 9,
organizationPublicId: NEW_ORGANIZATION_PUBLIC_ID,
organization: "vtoraya",
organizationName: "Вторая компания",
});
return route.fulfill({
status: 201,
json: { user: identityFor("OWNER", [membershipFor("OWNER"), membership]), organizationPublicId: NEW_ORGANIZATION_PUBLIC_ID },
});
});
await page.goto("/");
await expect(managerNav(page)).toHaveCount(MANAGER_NAV.length);
// В переключателе (A1) под списком организаций — «Добавить организацию».
await page.locator(".hub-brand-switch").click();
await page.locator(".app-dropdown").getByRole("button", { name: "Добавить организацию" }).click();
await expect(page).toHaveURL(/\/organizations\/new$/);
await expect(page.getByRole("heading", { name: "Новая организация" })).toBeVisible();
await page.getByPlaceholder("Например, «Ателье Норд»").fill("Вторая компания");
await page.getByRole("button", { name: "English" }).click();
await page.getByRole("button", { name: "Создать организацию" }).click();
// Сразу в новой организации: адрес и переключатель показывают её.
await expect(page).toHaveURL(new RegExp(`/organizations/${NEW_ORGANIZATION_PUBLIC_ID}/chat`));
await expect(page.locator(".hub-brand-switch span")).toHaveText("Вторая компания");
expect(created).toEqual({ name: "Вторая компания", timezone: "Europe/Moscow", currency: "RUB", language: "en" });
});
test("сотрудник без прав менеджера не видит «Добавить организацию»", async ({ page }) => {
await mockInstance(page);
await mockEmployees(page);
await mockSession(page, identityFor("EMPLOYEE"));
await page.goto("/");
await page.locator(".hub-brand-switch").click();
const menu = page.locator(".app-dropdown");
await expect(menu.getByRole("button", { name: "Ателье Норд" })).toBeVisible();
await expect(menu.getByRole("button", { name: "Добавить организацию" })).toHaveCount(0);
});
test("интерфейс работает на минимальной поддерживаемой ширине 1024px", async ({ page }) => {
await page.setViewportSize({ width: 1024, height: 768 });
await mockInstance(page);