mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1da84c9734 | ||
|
|
82805f7b1a | ||
|
|
eca0e52667 | ||
|
|
54c35e281a | ||
|
|
5396dda8ad | ||
|
|
29164db094 | ||
|
|
c5a53ccec5 | ||
|
|
049217505a | ||
|
|
132c684630 | ||
|
|
bfe90e9abd | ||
|
|
5b49904d6a | ||
|
|
cd50cce5a8 |
No files matched your search
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -132,7 +132,9 @@ Relay слушает выделенный IP, чтобы порт 443 не ко
|
||||
|
||||
### Обновление
|
||||
|
||||
Скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
Когда выходит новый релиз, администратор установки видит баннер в интерфейсе и обновляется одной кнопкой; то же есть в **Настройки → Платформа → Обновления**. Установка идёт на сервере сама: скачивается `compose.yaml` релиза, загружаются образы, сервисы перезапускаются, приложение недоступно около минуты.
|
||||
|
||||
Вручную, из консоли сервера: скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d --wait
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
+55
@@ -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)]
|
||||
@@ -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.
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UpdatesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "chatballs.updates"
|
||||
verbose_name = "Обновления установки"
|
||||
@@ -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.
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Язык интерфейса",
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -22,4 +22,5 @@ export const routes: Record<RouteKey, string> = {
|
||||
knowledgeCategories: t("shared.knowledge_categories"),
|
||||
knowledgeImport: t("shared.knowledge_import"),
|
||||
aiUsage: t("shared.ai_usage"),
|
||||
organizationCreate: t("organizations.create_title"),
|
||||
};
|
||||
@@ -1,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) — остался
|
||||
один скин, размер при необходимости задаётся модификатором. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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[];
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
@@ -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"]
|
||||
@@ -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 "готово"
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}$")
|
||||
|
||||
@@ -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 уедет с плавающим тегом — а найдётся это уже у человека.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user