diff --git a/.env.example b/.env.example index e1e664f..618cca9 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ CUSTOCRM_ADMIN_PORT=18001 CUS_ENV=local CUS_DEBUG=true +CUS_DELIVERY_MODE=CLOUD CUS_SECRET_KEY=change-me-only-for-local-development CUSTOCRM_APP_ALLOWED_HOSTS=localhost,127.0.0.1,app.localhost CUSTOCRM_APP_CSRF_TRUSTED_ORIGINS=http://localhost,http://app.localhost,http://localhost:8010,http://localhost:5173,http://localhost:5175 diff --git a/README.md b/README.md index 74c1ac8..3642e59 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,19 @@ Copy-Item .env.example .env .\scripts\start.ps1 ``` +По умолчанию локально запускается облачный режим. Коробочный режим запускается +тем же штатным контуром, но с явным признаком поставки: + +```powershell +.\scripts\start.ps1 -Mode Cloud +.\scripts\start.ps1 -Mode SelfHosted +``` + +Приложение не определяет режим по домену, числу организаций или данным тарифа. +Единственный источник — `CUS_DELIVERY_MODE` со значением `CLOUD` или +`SELF_HOSTED`. В production переменная обязательна; шаблон коробочного +экземпляра `env.example` уже содержит `SELF_HOSTED`. + The local compose stack contains: - isolated app, platform, and loopback-only admin Django runtimes; diff --git a/apps/backend/hub_backend/settings_base.py b/apps/backend/hub_backend/settings_base.py index f16ebe9..3e9fcbe 100644 --- a/apps/backend/hub_backend/settings_base.py +++ b/apps/backend/hub_backend/settings_base.py @@ -18,6 +18,14 @@ INSECURE_SECRET_KEY = "local-development-only" TESTING = "test" in sys.argv or "pytest" in sys.modules SECRET_KEY = os.environ.get("CUS_SECRET_KEY", INSECURE_SECRET_KEY) DEBUG = env_bool("CUS_DEBUG") +_delivery_mode = os.environ.get("CUS_DELIVERY_MODE", "").strip().upper() +if not _delivery_mode and (DEBUG or TESTING): + _delivery_mode = "CLOUD" +if _delivery_mode not in {"CLOUD", "SELF_HOSTED"}: + raise ImproperlyConfigured( + "CUS_DELIVERY_MODE must be CLOUD or SELF_HOSTED" + ) +CUS_DELIVERY_MODE = _delivery_mode ALLOWED_HOSTS = env_list("CUS_ALLOWED_HOSTS", ["localhost", "127.0.0.1"]) if TESTING: ALLOWED_HOSTS.extend(["testserver", ".help.custocrm.ru", ".localhost"]) diff --git a/apps/backend/hub_platform/api/permissions.py b/apps/backend/hub_platform/api/permissions.py index cfb9a6b..ba72f18 100644 --- a/apps/backend/hub_platform/api/permissions.py +++ b/apps/backend/hub_platform/api/permissions.py @@ -1,3 +1,4 @@ +from django.conf import settings from rest_framework.permissions import BasePermission from rest_framework.request import Request from rest_framework.views import APIView @@ -82,3 +83,12 @@ class HasEntitlement(BasePermission): # No subscription / inactive subscription -> entitlements unavailable. return False return policy.has_entitlement(entitlement) + + +class CloudDeliveryOnly(BasePermission): + """Guard tenant APIs that exist only in the managed CustoCRM Cloud.""" + + message = "This operation is available only in CustoCRM Cloud" + + def has_permission(self, request: Request, view: APIView) -> bool: + return settings.CUS_DELIVERY_MODE == "CLOUD" diff --git a/apps/backend/hub_platform/identity/administration_payloads.py b/apps/backend/hub_platform/identity/administration_payloads.py new file mode 100644 index 0000000..a4e3d2b --- /dev/null +++ b/apps/backend/hub_platform/identity/administration_payloads.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from zoneinfo import available_timezones + +from django.urls import reverse + +from hub_platform.identity.models import AuditEvent, Organization +from hub_platform.subscriptions.models import Subscription, UsageCounter +from hub_platform.subscriptions.policy import get_effective_policy +from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.models import OrganizationStorageUsage + + +QUOTA_LABELS = { + "new_dialogs_per_period": "Новые диалоги", + "managed_ai_credits": "AI-кредиты", + "storage_bytes": "Хранилище", +} + +AUDIT_ACTION_LABELS = { + "identity.profile_updated": "Изменён профиль сотрудника", + "identity.profile_password_changed": "Изменён пароль", + "identity.employee_created": "Добавлен сотрудник", + "identity.employee_blocked": "Сотрудник заблокирован", + "identity.employee_unblocked": "Сотрудник разблокирован", + "identity.ownership_transferred": "Передано владение организацией", + "identity.access_profile_created": "Создан профиль доступа", + "identity.access_profile_updated": "Изменён профиль доступа", + "identity.access_assignment_created": "Назначен доступ сотруднику", + "identity.access_assignment_revoked": "Отозван доступ сотрудника", + "administration.organization_updated": "Изменены данные организации", + "administration.logo_updated": "Изменён логотип организации", + "administration.logo_deleted": "Удалён логотип организации", + "products.product_created": "Добавлен продукт", + "products.product_updated": "Изменён продукт", + "products.product_active": "Продукт включён", + "products.product_disabled": "Продукт отключён", + "integrations.integration_created": "Добавлена интеграция", + "integrations.integration_updated": "Изменена интеграция", + "integrations.integration_deleted": "Удалена интеграция", + "channels.channel_created": "Создан канал", + "channels.channel_updated": "Изменён канал", + "channels.channel_deleted": "Удалён канал", + "ai.knowledge_created": "Добавлено знание", + "ai.knowledge_updated": "Изменено знание", + "ai.knowledge_deleted": "Удалено знание", +} + +AUDIT_RESULT_LABELS = { + "SUCCESS": "Выполнено", + "DENIED": "Отклонено", + "FAILED": "Ошибка", +} + + +def organization_settings_payload(organization: Organization) -> dict[str, object]: + return { + "name": organization.name, + "timezone": organization.timezone, + "currency": organization.currency, + "logoUrl": ( + reverse( + "organization-logo", + kwargs={"organization_public_id": organization.public_id}, + ) + if organization.logo + else None + ), + } + + +def administration_timezones() -> list[str]: + return sorted(available_timezones()) + + +def subscription_payload(context: TenantContext) -> dict[str, object]: + subscription = Subscription.objects.select_related("plan_version__plan").get( + organization_id=context.organization_id + ) + policy = get_effective_policy(context) + counters = { + counter.quota_definition.key: counter.used_value + for counter in UsageCounter.objects.select_related("quota_definition").filter( + organization_id=context.organization_id, + period__status="OPEN", + ) + } + storage = ( + OrganizationStorageUsage.objects.filter( + organization_id=context.organization_id, + ) + .values_list("bytes_used", flat=True) + .first() + ) + counters["storage_bytes"] = storage or 0 + quotas = [ + { + "key": quota.key, + "label": QUOTA_LABELS.get(quota.key, "Лимит"), + "mode": quota.mode, + "limit": quota.limit, + "used": counters.get(quota.key, 0), + "unit": quota.unit, + } + for quota in policy.quotas.values() + if quota.key in QUOTA_LABELS + ] + quotas.sort(key=lambda item: str(item["label"])) + return { + "planCode": subscription.plan_version.plan.code, + "planName": subscription.plan_version.plan.name, + "status": subscription.status, + "aiAgentQuantity": subscription.ai_agent_quantity, + "monthlyChargeMinor": subscription.monthly_charge_minor, + "currency": subscription.plan_version.currency, + "periodStart": ( + subscription.current_period_start.isoformat() + if subscription.current_period_start + else None + ), + "periodEnd": ( + subscription.current_period_end.isoformat() + if subscription.current_period_end + else None + ), + "quotas": quotas, + } + + +def audit_event_payload(event: AuditEvent) -> dict[str, object]: + actor = event.actor + return { + "id": event.id, + "createdAt": event.created_at.isoformat(), + "actor": ((actor.full_name or actor.email) if actor is not None else "Система"), + "action": AUDIT_ACTION_LABELS.get(event.action, "Системное действие"), + "result": event.result, + "resultLabel": AUDIT_RESULT_LABELS.get(event.result, "Неизвестно"), + } diff --git a/apps/backend/hub_platform/identity/administration_services.py b/apps/backend/hub_platform/identity/administration_services.py new file mode 100644 index 0000000..2898222 --- /dev/null +++ b/apps/backend/hub_platform/identity/administration_services.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile +from django.core.files.uploadedfile import UploadedFile +from django.db import transaction + +from hub_platform.identity.models import Organization +from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.storage import adjust_storage_usage +from hub_platform.tenancy.storage_quota import ( + finalize_storage, + release_storage, + reserve_storage, +) + + +MAX_LOGO_BYTES = 2 * 1024 * 1024 + + +@dataclass(frozen=True, slots=True) +class OrganizationSettingsInput: + name: str + timezone: str + currency: str + + +def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInput: + name = data.name.strip() + timezone = data.timezone.strip() + currency = data.currency.strip().upper() + if not name: + raise ValidationError({"name": "Укажите название организации"}) + if len(name) > 255: + raise ValidationError({"name": "Название не должно превышать 255 символов"}) + try: + ZoneInfo(timezone) + except (ValueError, ZoneInfoNotFoundError) as error: + raise ValidationError( + {"timezone": "Укажите корректный часовой пояс"} + ) from error + if currency != "RUB": + raise ValidationError( + {"currency": "Поддерживается только российский рубль (RUB)"} + ) + return OrganizationSettingsInput(name=name, timezone=timezone, currency=currency) + + +@transaction.atomic +def update_organization_settings( + *, + context: TenantContext, + data: OrganizationSettingsInput, +) -> Organization: + clean = _validate_input(data) + organization = Organization.objects.select_for_update().get( + pk=context.organization_id + ) + organization.name = clean.name + organization.timezone = clean.timezone + organization.currency = clean.currency + organization.save(update_fields=["name", "timezone", "currency"]) + return organization + + +def _image_type(data: bytes) -> tuple[str, str] | None: + if data.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png", ".png" + if data.startswith(b"\xff\xd8\xff"): + return "image/jpeg", ".jpg" + if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "image/webp", ".webp" + return None + + +@transaction.atomic +def replace_organization_logo( + *, + context: TenantContext, + upload: UploadedFile, +) -> Organization: + data = upload.read() + if not data: + raise ValidationError({"file": "Выберите файл логотипа"}) + if len(data) > MAX_LOGO_BYTES: + raise ValidationError({"file": "Размер логотипа не должен превышать 2 МБ"}) + detected = _image_type(data) + if detected is None: + raise ValidationError({"file": "Поддерживаются PNG, JPEG и WebP"}) + content_type, suffix = detected + organization = Organization.objects.select_for_update().get( + pk=context.organization_id + ) + previous_name = organization.logo.name + previous_size = organization.logo_size + reservation_key = f"organization-logo:{uuid.uuid4()}" + reserve_storage( + context=context, + expected_bytes=len(data), + idempotency_key=reservation_key, + ) + try: + organization.logo.save( + f"logo{suffix}", + ContentFile(data), + save=False, + ) + organization.logo_content_type = content_type + organization.logo_size = len(data) + organization.save( + update_fields=["logo", "logo_content_type", "logo_size"], + ) + if previous_name: + organization.logo.storage.delete(previous_name) + if previous_size: + adjust_storage_usage(context=context, delta_bytes=-previous_size) + except Exception: + release_storage(context=context, idempotency_key=reservation_key) + raise + finalize_storage( + context=context, + idempotency_key=reservation_key, + actual_bytes=len(data), + ) + return organization + + +@transaction.atomic +def delete_organization_logo(*, context: TenantContext) -> Organization: + organization = Organization.objects.select_for_update().get( + pk=context.organization_id + ) + previous_name = organization.logo.name + previous_size = organization.logo_size + organization.logo = "" + organization.logo_content_type = "" + organization.logo_size = 0 + organization.save(update_fields=["logo", "logo_content_type", "logo_size"]) + if previous_name: + organization.logo.storage.delete(previous_name) + if previous_size: + adjust_storage_usage(context=context, delta_bytes=-previous_size) + return organization diff --git a/apps/backend/hub_platform/identity/administration_views.py b/apps/backend/hub_platform/identity/administration_views.py new file mode 100644 index 0000000..005436b --- /dev/null +++ b/apps/backend/hub_platform/identity/administration_views.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from django.core.exceptions import ValidationError +from django.http import FileResponse +from rest_framework.parsers import FormParser, MultiPartParser +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 hub_platform.api.permissions import CloudDeliveryOnly, HasCapability +from hub_platform.identity.administration_payloads import ( + administration_timezones, + audit_event_payload, + organization_settings_payload, + subscription_payload, +) +from hub_platform.identity.administration_services import ( + OrganizationSettingsInput, + delete_organization_logo, + replace_organization_logo, + update_organization_settings, +) +from hub_platform.identity.audit import record_audit_event +from hub_platform.identity.models import AuditEvent +from hub_platform.subscriptions.models import Subscription + + +def _validation_response(error: ValidationError) -> Response: + if hasattr(error, "message_dict"): + payload = { + key: messages[0] if isinstance(messages, list) else str(messages) + for key, messages in error.message_dict.items() + } + detail = next(iter(payload.values()), "Проверьте заполненные поля") + return Response({"detail": detail, "errors": payload}, status=400) + return Response({"detail": "; ".join(error.messages)}, status=400) + + +class OrganizationSettingsView(APIView): + permission_classes = [HasCapability] + required_capabilities = { + "GET": "settings.view", + "PATCH": "settings.manage", + } + require_organization_scope = True + + def get(self, request: Request) -> Response: + return Response( + { + "organization": organization_settings_payload( + request.tenant_context.organization + ), + "timezones": administration_timezones(), + } + ) + + def patch(self, request: Request) -> Response: + organization = request.tenant_context.organization + body = request.data + try: + organization = update_organization_settings( + context=request.tenant_context, + data=OrganizationSettingsInput( + name=str(body.get("name", organization.name)), + timezone=str(body.get("timezone", organization.timezone)), + currency=str(body.get("currency", organization.currency)), + ), + ) + except ValidationError as error: + return _validation_response(error) + record_audit_event( + action="administration.organization_updated", + actor=request.user, + organization=organization, + object_type="Organization", + object_id=str(organization.public_id), + request=request, + ) + return Response({"organization": organization_settings_payload(organization)}) + + +class OrganizationLogoView(APIView): + parser_classes = [MultiPartParser, FormParser] + required_capabilities = { + "POST": "settings.manage", + "DELETE": "settings.manage", + } + require_organization_scope = True + + def get_permissions(self): + if self.request.method == "GET": + return [IsAuthenticated()] + return [HasCapability()] + + def get(self, request: Request): + organization = request.tenant_context.organization + if not organization.logo: + return Response({"detail": "Логотип не загружен"}, status=404) + return FileResponse( + organization.logo.open("rb"), + content_type=organization.logo_content_type or "application/octet-stream", + filename="organization-logo", + ) + + def post(self, request: Request) -> Response: + upload = request.FILES.get("file") + if upload is None: + return Response({"detail": "Выберите файл логотипа"}, status=400) + try: + organization = replace_organization_logo( + context=request.tenant_context, + upload=upload, + ) + except ValidationError as error: + return _validation_response(error) + record_audit_event( + action="administration.logo_updated", + actor=request.user, + organization=organization, + object_type="Organization", + object_id=str(organization.public_id), + request=request, + ) + return Response({"organization": organization_settings_payload(organization)}) + + def delete(self, request: Request) -> Response: + organization = delete_organization_logo(context=request.tenant_context) + record_audit_event( + action="administration.logo_deleted", + actor=request.user, + organization=organization, + object_type="Organization", + object_id=str(organization.public_id), + request=request, + ) + return Response({"organization": organization_settings_payload(organization)}) + + +class SubscriptionSummaryView(APIView): + permission_classes = [CloudDeliveryOnly, HasCapability] + required_capability = "settings.view" + require_organization_scope = True + + def get(self, request: Request) -> Response: + try: + payload = subscription_payload(request.tenant_context) + except Subscription.DoesNotExist: + return Response({"detail": "Тариф организации не найден"}, status=404) + return Response({"subscription": payload}) + + +class AuditListView(APIView): + permission_classes = [HasCapability] + required_capability = "audit.view" + require_organization_scope = True + + def get(self, request: Request) -> Response: + events = ( + AuditEvent.objects.filter( + organization_id=request.tenant_context.organization_id, + ) + .select_related("actor") + .order_by("-created_at")[:50] + ) + return Response({"items": [audit_event_payload(event) for event in events]}) diff --git a/apps/backend/hub_platform/identity/auth/common.py b/apps/backend/hub_platform/identity/auth/common.py index e499e36..6ea51b9 100644 --- a/apps/backend/hub_platform/identity/auth/common.py +++ b/apps/backend/hub_platform/identity/auth/common.py @@ -1,3 +1,4 @@ +from django.conf import settings from rest_framework.request import Request from hub_platform.identity.models import HumanUser, Organization, OrganizationMembership @@ -37,6 +38,12 @@ def _user_payload(user: HumanUser) -> dict[str, object]: "organizationPublicId": str(membership.organization.public_id), "organization": membership.organization.slug, "organizationName": membership.organization.name, + "organizationLogoUrl": ( + f"/api/v1/organizations/{membership.organization.public_id}" + "/company/administration/logo/" + if membership.organization.logo + else None + ), "role": membership.role, "positionTitle": membership.position_title, "department": ( @@ -53,6 +60,7 @@ def _user_payload(user: HumanUser) -> dict[str, object]: "fullName": user.full_name, "mustChangePassword": user.must_change_password, "totpEnabled": user.totp_enabled, + "deliveryMode": settings.CUS_DELIVERY_MODE, "memberships": memberships, } diff --git a/apps/backend/hub_platform/identity/company_urls.py b/apps/backend/hub_platform/identity/company_urls.py index 2ef1b56..48d2f3f 100644 --- a/apps/backend/hub_platform/identity/company_urls.py +++ b/apps/backend/hub_platform/identity/company_urls.py @@ -1,7 +1,27 @@ from django.urls import path -from hub_platform.identity import company_views +from hub_platform.identity import administration_views, company_views urlpatterns = [ path("departments/", company_views.DepartmentListView.as_view(), name="department-list"), + path( + "administration/", + administration_views.OrganizationSettingsView.as_view(), + name="organization-settings", + ), + path( + "administration/logo/", + administration_views.OrganizationLogoView.as_view(), + name="organization-logo", + ), + path( + "administration/subscription/", + administration_views.SubscriptionSummaryView.as_view(), + name="organization-subscription", + ), + path( + "administration/audit/", + administration_views.AuditListView.as_view(), + name="organization-audit", + ), ] diff --git a/apps/backend/hub_platform/identity/migrations/0017_organization_branding.py b/apps/backend/hub_platform/identity/migrations/0017_organization_branding.py new file mode 100644 index 0000000..8bf2a86 --- /dev/null +++ b/apps/backend/hub_platform/identity/migrations/0017_organization_branding.py @@ -0,0 +1,31 @@ +from django.db import migrations, models + +import hub_platform.identity.models + + +class Migration(migrations.Migration): + dependencies = [ + ("identity", "0016_channel_capabilities"), + ] + + operations = [ + migrations.AddField( + model_name="organization", + name="logo", + field=models.FileField( + blank=True, + max_length=512, + upload_to=hub_platform.identity.models.organization_logo_upload_path, + ), + ), + migrations.AddField( + model_name="organization", + name="logo_content_type", + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name="organization", + name="logo_size", + field=models.PositiveBigIntegerField(default=0), + ), + ] diff --git a/apps/backend/hub_platform/identity/models.py b/apps/backend/hub_platform/identity/models.py index 0c0012c..afc4a50 100644 --- a/apps/backend/hub_platform/identity/models.py +++ b/apps/backend/hub_platform/identity/models.py @@ -1,6 +1,7 @@ from __future__ import annotations import uuid +from pathlib import Path from django.conf import settings from django.contrib.auth.models import AbstractUser, UserManager @@ -68,6 +69,14 @@ class OrganizationStatus(models.TextChoices): PENDING_OWNER = "PENDING_OWNER", "Pending owner" +def organization_logo_upload_path(instance: "Organization", filename: str) -> str: + suffix = Path(filename).suffix.lower() + return ( + f"organizations/{instance.public_id}/branding/" + f"logo-{uuid.uuid4()}{suffix}" + ) + + class Organization(models.Model): public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) name = models.CharField(max_length=255) @@ -81,6 +90,13 @@ class Organization(models.Model): currency = models.CharField(max_length=3, default="RUB") tax_regime = models.CharField(max_length=32, choices=TaxRegime.choices, default=TaxRegime.USN_INCOME) vat_mode = models.CharField(max_length=32, choices=VatMode.choices, default=VatMode.WITHOUT_VAT) + logo = models.FileField( + upload_to=organization_logo_upload_path, + max_length=512, + blank=True, + ) + logo_content_type = models.CharField(max_length=64, blank=True) + logo_size = models.PositiveBigIntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) def __str__(self) -> str: diff --git a/apps/backend/hub_platform/identity/test_administration_api.py b/apps/backend/hub_platform/identity/test_administration_api.py new file mode 100644 index 0000000..3cccab0 --- /dev/null +++ b/apps/backend/hub_platform/identity/test_administration_api.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from tempfile import TemporaryDirectory + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings + +from hub_platform.identity.audit import record_audit_event +from hub_platform.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from hub_platform.subscriptions.default_subscription import ensure_default_subscription +from hub_platform.testing import TenantAPIClient + + +class AdministrationApiTests(TestCase): + def setUp(self) -> None: + self.media = TemporaryDirectory() + self.addCleanup(self.media.cleanup) + self.settings_override = override_settings(MEDIA_ROOT=self.media.name) + self.settings_override.enable() + self.addCleanup(self.settings_override.disable) + + self.organization = Organization.objects.create( + name="Example", + slug="administration", + timezone="Europe/Moscow", + currency="RUB", + ) + self.owner = HumanUser.objects.create_user( + email="owner@administration.test", + password="Password-123", + full_name="Владелец", + ) + OrganizationMembership.objects.create( + organization=self.organization, + user=self.owner, + role=EmployeeRole.OWNER, + position_title="Владелец", + ) + ensure_default_subscription(self.organization, quantity=3) + self.client = TenantAPIClient() + self.client.force_authenticate(self.owner) + + @override_settings(CUS_DELIVERY_MODE="CLOUD") + def test_session_exposes_delivery_mode_without_installation_inference(self) -> None: + response = self.client.get("/api/v1/auth/session/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["user"]["deliveryMode"], "CLOUD") + + def test_settings_payload_contains_only_current_administration_fields(self) -> None: + response = self.client.get("/api/v1/company/administration/") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json()["organization"], + { + "name": "Example", + "timezone": "Europe/Moscow", + "currency": "RUB", + "logoUrl": None, + }, + ) + self.assertIn("Europe/Moscow", response.json()["timezones"]) + self.assertGreater(len(response.json()["timezones"]), 300) + self.assertNotIn("taxRegime", response.json()["organization"]) + + def test_owner_updates_current_organization(self) -> None: + response = self.client.patch( + "/api/v1/company/administration/", + { + "name": "Новая компания", + "timezone": "Asia/Yekaterinburg", + "currency": "rub", + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.organization.refresh_from_db() + self.assertEqual(self.organization.name, "Новая компания") + self.assertEqual(self.organization.timezone, "Asia/Yekaterinburg") + self.assertEqual(self.organization.currency, "RUB") + + def test_invalid_timezone_is_rejected(self) -> None: + response = self.client.patch( + "/api/v1/company/administration/", + {"timezone": "Nowhere/Invalid"}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("timezone", response.json()["errors"]) + + def test_only_ruble_currency_is_accepted(self) -> None: + response = self.client.patch( + "/api/v1/company/administration/", + {"currency": "USD"}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("российский рубль", response.json()["errors"]["currency"]) + + def test_logo_upload_download_and_delete(self) -> None: + png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + uploaded = self.client.post( + "/api/v1/company/administration/logo/", + {"file": SimpleUploadedFile("logo.png", png, content_type="image/png")}, + format="multipart", + ) + + self.assertEqual(uploaded.status_code, 200) + logo_url = uploaded.json()["organization"]["logoUrl"] + self.assertTrue(logo_url.endswith("/company/administration/logo/")) + session = self.client.get("/api/v1/auth/session/") + self.assertEqual( + session.json()["user"]["memberships"][0]["organizationLogoUrl"], + logo_url, + ) + + downloaded = self.client.get("/api/v1/company/administration/logo/") + self.assertEqual(downloaded.status_code, 200) + self.assertEqual(downloaded["Content-Type"], "image/png") + self.assertEqual(b"".join(downloaded.streaming_content), png) + + deleted = self.client.delete("/api/v1/company/administration/logo/") + self.assertEqual(deleted.status_code, 200) + self.assertIsNone(deleted.json()["organization"]["logoUrl"]) + + def test_non_image_logo_is_rejected(self) -> None: + response = self.client.post( + "/api/v1/company/administration/logo/", + { + "file": SimpleUploadedFile( + "logo.svg", + b"", + content_type="image/svg+xml", + ) + }, + format="multipart", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("PNG, JPEG и WebP", response.json()["detail"]) + + @override_settings(CUS_DELIVERY_MODE="CLOUD") + def test_cloud_exposes_real_subscription_summary(self) -> None: + response = self.client.get("/api/v1/company/administration/subscription/") + + self.assertEqual(response.status_code, 200) + subscription = response.json()["subscription"] + self.assertEqual(subscription["planName"], "Стартап") + self.assertEqual(subscription["aiAgentQuantity"], 3) + self.assertEqual(subscription["monthlyChargeMinor"], 870_000) + + @override_settings(CUS_DELIVERY_MODE="SELF_HOSTED") + def test_self_hosted_rejects_cloud_subscription_endpoint(self) -> None: + response = self.client.get("/api/v1/company/administration/subscription/") + + self.assertEqual(response.status_code, 403) + + def test_audit_returns_product_text_instead_of_internal_action_codes(self) -> None: + record_audit_event( + action="products.product_created", + actor=self.owner, + organization=self.organization, + ) + + response = self.client.get("/api/v1/company/administration/audit/") + + self.assertEqual(response.status_code, 200) + event = response.json()["items"][0] + self.assertEqual(event["action"], "Добавлен продукт") + self.assertNotIn("products.", event["action"]) diff --git a/apps/backend/hub_platform/tenancy/migrations/0012_organization_settings_rls.py b/apps/backend/hub_platform/tenancy/migrations/0012_organization_settings_rls.py new file mode 100644 index 0000000..b7139d3 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/migrations/0012_organization_settings_rls.py @@ -0,0 +1,56 @@ +from django.db import migrations + + +FORWARD_SQL = """ +ALTER TABLE identity_organization OWNER TO custocrm_schema; +ALTER TABLE identity_organization ENABLE ROW LEVEL SECURITY; +ALTER TABLE identity_organization FORCE ROW LEVEL SECURITY; +REVOKE ALL ON identity_organization FROM PUBLIC; + +GRANT SELECT, UPDATE ON identity_organization TO custocrm_runtime_app; +GRANT SELECT, INSERT, UPDATE ON identity_organization TO custocrm_runtime_platform; +GRANT ALL ON identity_organization TO custocrm_schema; + +DROP POLICY IF EXISTS custocrm_organization_app_select ON identity_organization; +CREATE POLICY custocrm_organization_app_select ON identity_organization + FOR SELECT TO custocrm_runtime_app + USING (true); + +DROP POLICY IF EXISTS custocrm_organization_app_update ON identity_organization; +CREATE POLICY custocrm_organization_app_update ON identity_organization + FOR UPDATE TO custocrm_runtime_app + USING (id = custocrm.current_organization_id()) + WITH CHECK (id = custocrm.current_organization_id()); + +DROP POLICY IF EXISTS custocrm_organization_platform ON identity_organization; +CREATE POLICY custocrm_organization_platform ON identity_organization + FOR ALL TO custocrm_runtime_platform + USING (true) + WITH CHECK (true); + +DROP POLICY IF EXISTS custocrm_organization_schema ON identity_organization; +CREATE POLICY custocrm_organization_schema ON identity_organization + FOR ALL TO custocrm_schema + USING (true) + WITH CHECK (true); +""" + + +REVERSE_SQL = """ +DROP POLICY IF EXISTS custocrm_organization_app_select ON identity_organization; +DROP POLICY IF EXISTS custocrm_organization_app_update ON identity_organization; +DROP POLICY IF EXISTS custocrm_organization_platform ON identity_organization; +DROP POLICY IF EXISTS custocrm_organization_schema ON identity_organization; +ALTER TABLE identity_organization NO FORCE ROW LEVEL SECURITY; +ALTER TABLE identity_organization DISABLE ROW LEVEL SECURITY; +REVOKE UPDATE ON identity_organization FROM custocrm_runtime_app; +""" + + +class Migration(migrations.Migration): + dependencies = [ + ("identity", "0017_organization_branding"), + ("tenancy", "0011_support_portal_host_directory"), + ] + + operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)] diff --git a/apps/backend/hub_platform/tenancy/migrations/0013_storage_reservation_rls.py b/apps/backend/hub_platform/tenancy/migrations/0013_storage_reservation_rls.py new file mode 100644 index 0000000..de20a42 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/migrations/0013_storage_reservation_rls.py @@ -0,0 +1,46 @@ +from django.db import migrations + + +FORWARD_SQL = """ +ALTER TABLE tenancy_storagereservation OWNER TO custocrm_schema; +ALTER TABLE tenancy_storagereservation ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenancy_storagereservation FORCE ROW LEVEL SECURITY; +REVOKE ALL ON tenancy_storagereservation FROM PUBLIC; + +GRANT SELECT, INSERT, UPDATE, DELETE + ON tenancy_storagereservation TO custocrm_runtime_app; +GRANT ALL ON tenancy_storagereservation TO custocrm_schema; +GRANT USAGE, SELECT ON SEQUENCE tenancy_storagereservation_id_seq + TO custocrm_runtime_app, custocrm_schema; + +DROP POLICY IF EXISTS custocrm_tenant_isolation ON tenancy_storagereservation; +CREATE POLICY custocrm_tenant_isolation ON tenancy_storagereservation + FOR ALL TO custocrm_runtime_app + USING (organization_id = custocrm.current_organization_id()) + WITH CHECK (organization_id = custocrm.current_organization_id()); + +DROP POLICY IF EXISTS custocrm_schema_access ON tenancy_storagereservation; +CREATE POLICY custocrm_schema_access ON tenancy_storagereservation + FOR ALL TO custocrm_schema + USING (true) + WITH CHECK (true); +""" + + +REVERSE_SQL = """ +DROP POLICY IF EXISTS custocrm_tenant_isolation ON tenancy_storagereservation; +DROP POLICY IF EXISTS custocrm_schema_access ON tenancy_storagereservation; +ALTER TABLE tenancy_storagereservation NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenancy_storagereservation DISABLE ROW LEVEL SECURITY; +REVOKE ALL ON tenancy_storagereservation FROM custocrm_runtime_app; +REVOKE USAGE, SELECT ON SEQUENCE tenancy_storagereservation_id_seq + FROM custocrm_runtime_app; +""" + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0012_organization_settings_rls"), + ] + + operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)] diff --git a/apps/backend/hub_platform/tenancy/test_rls.py b/apps/backend/hub_platform/tenancy/test_rls.py index d32f9dd..c1af341 100644 --- a/apps/backend/hub_platform/tenancy/test_rls.py +++ b/apps/backend/hub_platform/tenancy/test_rls.py @@ -15,6 +15,7 @@ from hub_platform.identity.models import ( ) from hub_platform.products.models import Product from hub_platform.tenancy.database import current_tenant_id, set_local_tenant +from hub_platform.tenancy.models import StorageReservation from hub_platform.testing import TenantAPIClient @@ -87,13 +88,17 @@ class RowLevelSecurityTests(TransactionTestCase): with transaction.atomic(): self._set_role("custocrm_runtime_app") set_local_tenant(self.first.id) - self.assertEqual(list(Product.objects.values_list("code", flat=True)), ["first"]) + self.assertEqual( + list(Product.objects.values_list("code", flat=True)), ["first"] + ) Product.objects.create( organization_id=self.first.id, code="created", name="Created", ) - self.assertEqual(Product.objects.filter(code="created").update(name="Updated"), 1) + self.assertEqual( + Product.objects.filter(code="created").update(name="Updated"), 1 + ) self.assertEqual(Product.objects.filter(code="created").delete()[0], 1) with self.assertRaises(DatabaseError), transaction.atomic(): @@ -105,6 +110,52 @@ class RowLevelSecurityTests(TransactionTestCase): name="Forged", ) + def test_app_role_updates_only_current_organization(self) -> None: + with transaction.atomic(): + self._set_role("custocrm_runtime_app") + set_local_tenant(self.first.id) + self.assertEqual( + Organization.objects.filter(id=self.first.id).update( + name="Updated first" + ), + 1, + ) + self.assertEqual( + Organization.objects.filter(id=self.second.id).update( + name="Forged second" + ), + 0, + ) + + self.first.refresh_from_db() + self.second.refresh_from_db() + self.assertEqual(self.first.name, "Updated first") + self.assertEqual(self.second.name, "Second") + + def test_app_role_manages_only_own_storage_reservations(self) -> None: + with transaction.atomic(): + self._set_role("custocrm_runtime_app") + set_local_tenant(self.first.id) + reservation = StorageReservation.objects.create( + organization=self.first, + idempotency_key="rls-storage", + reserved_bytes=128, + ) + self.assertIsNotNone(reservation.pk) + self.assertEqual( + StorageReservation.objects.filter(organization=self.second).count(), + 0, + ) + + with self.assertRaises(DatabaseError), transaction.atomic(): + self._set_role("custocrm_runtime_app") + set_local_tenant(self.first.id) + StorageReservation.objects.create( + organization=self.second, + idempotency_key="rls-forged-storage", + reserved_bytes=128, + ) + def test_app_role_writes_platform_audit_event_without_tenant_context(self) -> None: # Login-failed audit path: app role, no tenant context, organization NULL. # ORM create() issues INSERT ... RETURNING id, which also requires SELECT diff --git a/apps/internal-ui/src/auth/access.test.ts b/apps/internal-ui/src/auth/access.test.ts index 607b44b..594773e 100644 --- a/apps/internal-ui/src/auth/access.test.ts +++ b/apps/internal-ui/src/auth/access.test.ts @@ -11,10 +11,12 @@ const baseUser: Omit { expect(canAccess(user, "employees")).toBe(true); expect(canAccess(user, "accessProfiles")).toBe(true); expect(canAccess(user, "products")).toBe(true); + expect(canAccess(user, "administrationOrganization")).toBe(false); expect(canAccess(user, "integrations")).toBe(false); expect(defaultRoute(user)).toBe("command"); }); + it("opens administration subsections only with their capabilities", () => { + const settingsUser = userWith(["settings.view"]); + const auditUser = userWith(["audit.view"]); + expect(canAccess(settingsUser, "administrationOrganization")).toBe(true); + expect(canAccess(settingsUser, "administrationSubscription")).toBe(true); + expect(canAccess(settingsUser, "administrationAudit")).toBe(false); + expect(canAccess(auditUser, "administrationOrganization")).toBe(false); + expect(canAccess(auditUser, "administrationAudit")).toBe(true); + }); + + it("does not expose the cloud subscription in a self-hosted installation", () => { + const user = userWith(["settings.view"]); + user.deliveryMode = "SELF_HOSTED"; + expect(canAccess(user, "administrationSubscription")).toBe(false); + }); + it("keeps sales and support department scopes isolated", () => { const capabilities = ["conversations.view", "sales.view", "customers.view"]; const sales = userWith(capabilities, "sales"); diff --git a/apps/internal-ui/src/auth/access.ts b/apps/internal-ui/src/auth/access.ts index 7e37561..766df4a 100644 --- a/apps/internal-ui/src/auth/access.ts +++ b/apps/internal-ui/src/auth/access.ts @@ -4,6 +4,9 @@ type RouteAccess = { capability: string; departmentCode?: string }; const ROUTE_ACCESS: Partial> = { accessProfiles: { capability: "employees.manage" }, + administrationOrganization: { capability: "settings.view" }, + administrationSubscription: { capability: "settings.view" }, + administrationAudit: { capability: "audit.view" }, command: { capability: "company.view" }, departments: { capability: "departments.view" }, employees: { capability: "employees.view" }, @@ -63,6 +66,9 @@ export function scopeDepartments(user: SessionUser, capability: string): string[ export function canAccess(user: SessionUser, route: RouteKey): boolean { if (route === "profile" || route === "settings") return true; + if (route === "administrationSubscription" && user.deliveryMode !== "CLOUD") { + return false; + } const requirement = ROUTE_ACCESS[route]; return requirement ? hasCapability(user, requirement.capability, requirement.departmentCode) diff --git a/apps/internal-ui/src/features/administration/AdministrationPage.tsx b/apps/internal-ui/src/features/administration/AdministrationPage.tsx new file mode 100644 index 0000000..a3af551 --- /dev/null +++ b/apps/internal-ui/src/features/administration/AdministrationPage.tsx @@ -0,0 +1,73 @@ +import { EmptyState, LoadingState, PageHeader } from "../../shared/ui"; +import { Button } from "../../shared/ui-controls"; +import type { SessionUser } from "../../types"; +import { AuditTable } from "./AuditTable"; +import { CloudSubscriptionPanel } from "./CloudSubscriptionPanel"; +import { + administrationSection, + canManageSettings, + type AdministrationRoute, +} from "./model"; +import { OrganizationSettingsForm } from "./OrganizationSettingsForm"; +import { useAdministration } from "./useAdministration"; + +export function AdministrationPage({ + route, + user, + onUserUpdated, +}: { + route: AdministrationRoute; + user: SessionUser; + onUserUpdated: (user: SessionUser) => void; +}) { + const section = administrationSection(route); + const page = useAdministration({ section, user, onUserUpdated }); + const header = ( + + ); + + if (page.loading) { + return
{header}
; + } + if (section === "organization" && !page.organization) { + return ( +
+ {header} + + +
+ ); + } + + return ( +
+ {header} + {section === "organization" && page.organization && ( + void page.save()} + onUploadLogo={(file) => void page.uploadLogo(file)} + onRemoveLogo={() => void page.removeLogo()} + /> + )} + {section === "subscription" && page.subscription && ( + + )} + {section === "subscription" && !page.subscription && ( +
+ +
+ )} + {section === "audit" && } +
+ ); +} diff --git a/apps/internal-ui/src/features/administration/AuditTable.tsx b/apps/internal-ui/src/features/administration/AuditTable.tsx new file mode 100644 index 0000000..99607f5 --- /dev/null +++ b/apps/internal-ui/src/features/administration/AuditTable.tsx @@ -0,0 +1,51 @@ +import { formatDate } from "../../shared/utils"; +import type { AuditEvent } from "./model"; + +function formatTime(value: string): string { + return new Intl.DateTimeFormat("ru-RU", { + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +export function AuditTable({ events }: { events: AuditEvent[] }) { + if (events.length === 0) { + return ( +
+
Событий пока нет
+
+ ); + } + + return ( +
+ + + + + + + + + + + {events.map((event) => ( + + + + + + + ))} + +
ДатаСотрудникДействиеРезультат
+ {formatDate(event.createdAt)} + {formatTime(event.createdAt)} + {event.actor}{event.action} + + {event.resultLabel} + +
+
+ ); +} diff --git a/apps/internal-ui/src/features/administration/CloudSubscriptionPanel.tsx b/apps/internal-ui/src/features/administration/CloudSubscriptionPanel.tsx new file mode 100644 index 0000000..9ac507e --- /dev/null +++ b/apps/internal-ui/src/features/administration/CloudSubscriptionPanel.tsx @@ -0,0 +1,62 @@ +import { formatDate } from "../../shared/utils"; +import { + formatMoney, + formatQuotaValue, + subscriptionStatusLabel, + type SubscriptionSummary, +} from "./model"; + +export function CloudSubscriptionPanel({ + subscription, +}: { + subscription: SubscriptionSummary; +}) { + const period = subscription.periodStart && subscription.periodEnd + ? `${formatDate(subscription.periodStart)} — ${formatDate(subscription.periodEnd)}` + : "—"; + + return ( +
+
+
+ Тариф + {subscription.planName} +
+
+ Статус + {subscriptionStatusLabel(subscription.status)} +
+
+ AI-агенты + {subscription.aiAgentQuantity.toLocaleString("ru-RU")} +
+
+ Стоимость в месяц + {formatMoney(subscription.monthlyChargeMinor, subscription.currency)} +
+
+ Текущий период + {period} +
+
+ {subscription.quotas.length > 0 && ( +
+

Использование

+
+ {subscription.quotas.map((quota) => ( +
+ {quota.label} + + {formatQuotaValue(quota.used, quota.unit)} + {quota.limit === null + ? "" + : ` из ${formatQuotaValue(quota.limit, quota.unit)}`} + +
+ ))} +
+
+ )} +
+ ); +} diff --git a/apps/internal-ui/src/features/administration/OrganizationLogoField.tsx b/apps/internal-ui/src/features/administration/OrganizationLogoField.tsx new file mode 100644 index 0000000..af79b9f --- /dev/null +++ b/apps/internal-ui/src/features/administration/OrganizationLogoField.tsx @@ -0,0 +1,93 @@ +import { useRef, useState } from "react"; + +import { DecisionDialog } from "../../shared/DecisionDialog"; +import { Button } from "../../shared/ui-controls"; + +export function OrganizationLogoField({ + logoUrl, + name, + disabled, + saving, + onUpload, + onRemove, +}: { + logoUrl: string | null; + name: string; + disabled: boolean; + saving: boolean; + onUpload: (file: File) => void; + onRemove: () => void; +}) { + const input = useRef(null); + const [confirmingRemoval, setConfirmingRemoval] = useState(false); + + return ( +
+
+ {logoUrl + ? {`Логотип + : {name.trim().slice(0, 2).toUpperCase() || "CR"}} +
+
+ Логотип + PNG, JPEG или WebP · до 2 МБ + {!disabled && ( +
+ { + const file = event.target.files?.[0]; + if (file) onUpload(file); + event.target.value = ""; + }} + /> + + {logoUrl && ( + + )} +
+ )} +
+ setConfirmingRemoval(false)} + tone="danger" + icon="trash" + title="Удалить логотип?" + description="Вместо него в меню снова будет показан знак CustoCRM." + actions={( + <> + + + + )} + /> +
+ ); +} diff --git a/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx b/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx new file mode 100644 index 0000000..a73275e --- /dev/null +++ b/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx @@ -0,0 +1,87 @@ +import type { FormEvent } from "react"; + +import { FormField, SelectField } from "../../shared/form-controls"; +import { Button } from "../../shared/ui-controls"; +import type { OrganizationSettings } from "./model"; +import { OrganizationLogoField } from "./OrganizationLogoField"; + +export function OrganizationSettingsForm({ + organization, + canManage, + saving, + message, + error, + timezones, + onChange, + onSave, + onUploadLogo, + onRemoveLogo, +}: { + organization: OrganizationSettings; + canManage: boolean; + saving: boolean; + message: string; + error: string; + timezones: string[]; + onChange: (next: OrganizationSettings) => void; + onSave: () => void; + onUploadLogo: (file: File) => void; + onRemoveLogo: () => void; +}) { + function submit(event: FormEvent) { + event.preventDefault(); + onSave(); + } + + return ( +
+ +
+ onChange({ ...organization, name }) + : undefined} + /> + onChange({ ...organization, timezone })} + options={timezones.map((timezone) => [timezone, timezone])} + /> +
+ onChange({ ...organization, currency })} + options={[["RUB", "Российский рубль (RUB)"]]} + /> +
+
+ {error &&
{error}
} + {message &&
{message}
} + {canManage && ( +
+ +
+ )} + + ); +} diff --git a/apps/internal-ui/src/features/administration/api.ts b/apps/internal-ui/src/features/administration/api.ts new file mode 100644 index 0000000..6bea18f --- /dev/null +++ b/apps/internal-ui/src/features/administration/api.ts @@ -0,0 +1,59 @@ +import { api, apiUpload } from "../../api/client"; +import type { + AuditEvent, + OrganizationSettings, + SubscriptionSummary, +} from "./model"; + +const BASE = "/api/v1/company/administration/"; + +export async function loadOrganizationSettings(): Promise<{ + organization: OrganizationSettings; + timezones: string[]; +}> { + return api<{ organization: OrganizationSettings; timezones: string[] }>(BASE); +} + +export async function saveOrganizationSettings( + settings: OrganizationSettings, +): Promise { + const payload = await api<{ organization: OrganizationSettings }>(BASE, { + method: "PATCH", + body: JSON.stringify({ + name: settings.name, + timezone: settings.timezone, + currency: settings.currency, + }), + }); + return payload.organization; +} + +export async function uploadOrganizationLogo(file: File): Promise { + const form = new FormData(); + form.append("file", file); + const payload = await apiUpload<{ organization: OrganizationSettings }>( + `${BASE}logo/`, + form, + ); + return payload.organization; +} + +export async function removeOrganizationLogo(): Promise { + const payload = await api<{ organization: OrganizationSettings }>( + `${BASE}logo/`, + { method: "DELETE" }, + ); + return payload.organization; +} + +export async function loadSubscription(): Promise { + const payload = await api<{ subscription: SubscriptionSummary }>( + `${BASE}subscription/`, + ); + return payload.subscription; +} + +export async function loadAudit(): Promise { + const payload = await api<{ items: AuditEvent[] }>(`${BASE}audit/`); + return payload.items; +} diff --git a/apps/internal-ui/src/features/administration/model.test.ts b/apps/internal-ui/src/features/administration/model.test.ts new file mode 100644 index 0000000..1bd5918 --- /dev/null +++ b/apps/internal-ui/src/features/administration/model.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { + administrationSection, + formatQuotaValue, + subscriptionStatusLabel, +} from "./model"; + +describe("administration model", () => { + it("maps administration routes to their sections", () => { + expect(administrationSection("administrationOrganization")).toBe("organization"); + expect(administrationSection("administrationSubscription")).toBe("subscription"); + expect(administrationSection("administrationAudit")).toBe("audit"); + }); + + it("formats subscription values for the product UI", () => { + expect(formatQuotaValue(1024 ** 3, "bytes")).toBe("1 ГБ"); + expect(subscriptionStatusLabel("ACTIVE")).toBe("Активен"); + expect(subscriptionStatusLabel("UNKNOWN")).toBe("Не определён"); + }); +}); diff --git a/apps/internal-ui/src/features/administration/model.ts b/apps/internal-ui/src/features/administration/model.ts new file mode 100644 index 0000000..8442563 --- /dev/null +++ b/apps/internal-ui/src/features/administration/model.ts @@ -0,0 +1,84 @@ +import type { RouteKey, SessionUser } from "../../types"; +import { hasCapability } from "../../auth/access"; + +export type AdministrationRoute = Extract< + RouteKey, + "administrationOrganization" | "administrationSubscription" | "administrationAudit" +>; + +export type AdministrationSection = "organization" | "subscription" | "audit"; + +export type OrganizationSettings = { + name: string; + timezone: string; + currency: string; + logoUrl: string | null; +}; + +export type SubscriptionQuota = { + key: string; + label: string; + mode: string; + limit: number | null; + used: number; + unit: string; +}; + +export type SubscriptionSummary = { + planCode: string; + planName: string; + status: string; + aiAgentQuantity: number; + monthlyChargeMinor: number; + currency: string; + periodStart: string | null; + periodEnd: string | null; + quotas: SubscriptionQuota[]; +}; + +export type AuditEvent = { + id: number; + createdAt: string; + actor: string; + action: string; + result: "SUCCESS" | "DENIED" | "FAILED"; + resultLabel: string; +}; + +export function administrationSection(route: AdministrationRoute): AdministrationSection { + if (route === "administrationSubscription") return "subscription"; + if (route === "administrationAudit") return "audit"; + return "organization"; +} + +export function canManageSettings(user: SessionUser): boolean { + return hasCapability(user, "settings.manage"); +} + +export function formatMoney(amountMinor: number, currency: string): string { + return new Intl.NumberFormat("ru-RU", { + style: "currency", + currency, + maximumFractionDigits: 0, + }).format(amountMinor / 100); +} + +export function formatQuotaValue(value: number, unit: string): string { + if (unit === "bytes") { + if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} ГБ`; + return `${(value / 1024 ** 2).toLocaleString("ru-RU", { maximumFractionDigits: 0 })} МБ`; + } + return value.toLocaleString("ru-RU"); +} + +const SUBSCRIPTION_STATUS_LABELS: Record = { + ACTIVE: "Активен", + CANCELLED: "Завершён", + GRACE_PERIOD: "Льготный период", + PENDING_PAYMENT: "Ожидает оплаты", + SUSPENDED: "Приостановлен", +}; + +export function subscriptionStatusLabel(status: string): string { + return SUBSCRIPTION_STATUS_LABELS[status] ?? "Не определён"; +} diff --git a/apps/internal-ui/src/features/administration/styles.css b/apps/internal-ui/src/features/administration/styles.css new file mode 100644 index 0000000..5be5f2d --- /dev/null +++ b/apps/internal-ui/src/features/administration/styles.css @@ -0,0 +1,211 @@ +.administration-page { + display: flex; + flex-direction: column; + gap: 18px; + width: 100%; + max-width: 900px; +} + +.administration-page .page-header { + margin-bottom: 0; +} + +.administration-card { + width: 100%; + padding: 20px; + background: #ffffff; + border: 1px solid var(--border-default); + border-radius: var(--radius-2xl); + box-shadow: var(--shadow-sm); +} + +.administration-logo-field { + display: flex; + align-items: center; + gap: 14px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border-default); +} + +.administration-logo-preview { + width: 56px; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + flex: none; + overflow: hidden; + color: var(--primary); + background: var(--primary-bg); + border: 1px solid var(--primary-border); + border-radius: var(--radius-xl); + font-size: 17px; + font-weight: 700; +} + +.administration-logo-preview img { + width: 100%; + height: 100%; + object-fit: contain; + background: #ffffff; +} + +.administration-logo-copy { + display: flex; + flex-direction: column; + gap: 3px; +} + +.administration-logo-copy > strong { + color: var(--text-body); + font-size: 13px; +} + +.administration-logo-copy > span { + color: var(--text-tertiary); + font-size: 11.5px; +} + +.administration-logo-actions { + display: flex; + gap: 8px; + margin-top: 7px; +} + +.administration-logo-actions button { + height: 30px; + padding-inline: 10px; + font-size: 12px; +} + +.administration-fields { + display: grid; + grid-template-columns: minmax(280px, 460px) 220px; + gap: 14px; + align-items: end; + margin-top: 18px; +} + +.administration-currency { + width: 220px; +} + +.administration-message { + margin-top: 14px; + padding: 9px 12px; + color: var(--primary-hover); + background: var(--primary-bg); + border: 1px solid var(--primary-border); + border-radius: var(--radius-lg); + font-size: 12.5px; +} + +.administration-message.error { + color: var(--error-text); + background: var(--error-bg); + border-color: var(--error-border); +} + +.administration-actions { + display: flex; + justify-content: flex-end; + margin-top: 18px; +} + +.administration-subscription-summary { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 16px; +} + +.administration-subscription-summary > div { + min-width: 0; +} + +.administration-subscription-summary .is-wide { + grid-column: span 2; +} + +.administration-subscription-summary span, +.administration-quotas article span { + display: block; + margin-bottom: 5px; + color: var(--text-tertiary); + font-size: 11.5px; +} + +.administration-subscription-summary strong, +.administration-quotas article strong { + color: var(--text-body); + font-size: 13.5px; + font-weight: 600; +} + +.administration-quotas { + margin-top: 20px; + padding-top: 18px; + border-top: 1px solid var(--border-default); +} + +.administration-quotas h3 { + margin: 0 0 12px; + color: var(--text-heading); + font-size: 13px; +} + +.administration-quotas > div { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 12px; +} + +.administration-quotas article { + padding: 12px; + background: var(--n-10); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); +} + +.administration-audit { + padding: 0; + overflow-x: auto; +} + +.administration-audit .baseline-table { + min-width: 720px; +} + +.administration-audit td { + padding: 12px; +} + +.administration-audit td:first-child span, +.administration-audit td:first-child small { + display: block; +} + +.administration-audit td:first-child small { + margin-top: 2px; + color: var(--text-tertiary); +} + +.administration-audit-result { + color: var(--success-text); + font-weight: 600; +} + +.administration-audit-result.is-denied, +.administration-audit-result.is-failed { + color: var(--error-text); +} + +@media (max-width: 1120px) { + .administration-fields { + grid-template-columns: minmax(280px, 420px) 200px; + } + + .administration-subscription-summary, + .administration-quotas > div { + grid-template-columns: repeat(3, minmax(120px, 1fr)); + } +} diff --git a/apps/internal-ui/src/features/administration/useAdministration.ts b/apps/internal-ui/src/features/administration/useAdministration.ts new file mode 100644 index 0000000..60b3204 --- /dev/null +++ b/apps/internal-ui/src/features/administration/useAdministration.ts @@ -0,0 +1,113 @@ +import { useCallback, useEffect, useState } from "react"; + +import { api, ApiError } from "../../api/client"; +import { hasCapability } from "../../auth/access"; +import type { SessionUser } from "../../types"; +import { + loadAudit, + loadOrganizationSettings, + loadSubscription, + removeOrganizationLogo, + saveOrganizationSettings, + uploadOrganizationLogo, +} from "./api"; +import type { + AdministrationSection, + AuditEvent, + OrganizationSettings, + SubscriptionSummary, +} from "./model"; + +export function useAdministration({ + section, + user, + onUserUpdated, +}: { + section: AdministrationSection; + user: SessionUser; + onUserUpdated: (user: SessionUser) => void; +}) { + const [organization, setOrganization] = useState(null); + const [timezones, setTimezones] = useState([]); + const [subscription, setSubscription] = useState(null); + const [audit, setAudit] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + + const refreshIdentity = useCallback(async () => { + const payload = await api<{ authenticated: boolean; user?: SessionUser }>( + "/api/v1/auth/session/", + ); + if (payload.authenticated && payload.user) onUserUpdated(payload.user); + }, [onUserUpdated]); + + const load = useCallback(async () => { + setLoading(true); + setError(""); + try { + if (section === "organization") { + const settings = await loadOrganizationSettings(); + setOrganization(settings.organization); + setTimezones(settings.timezones); + } else if (section === "subscription" && user.deliveryMode === "CLOUD") { + setSubscription(await loadSubscription()); + } else if (section === "audit" && hasCapability(user, "audit.view")) { + setAudit(await loadAudit()); + } + } catch (loadError) { + setError(loadError instanceof ApiError ? loadError.message : "Не удалось загрузить данные"); + } finally { + setLoading(false); + } + }, [section, user]); + + useEffect(() => { + void load(); + }, [load]); + + const runChange = useCallback(async ( + change: () => Promise, + successMessage: string, + ) => { + setSaving(true); + setError(""); + setMessage(""); + try { + const next = await change(); + setOrganization(next); + setMessage(successMessage); + await refreshIdentity(); + } catch (changeError) { + setError(changeError instanceof ApiError ? changeError.message : "Не удалось сохранить изменения"); + } finally { + setSaving(false); + } + }, [refreshIdentity]); + + return { + audit, + error, + loading, + message, + organization, + saving, + subscription, + timezones, + setOrganization, + reload: load, + save: () => organization && runChange( + () => saveOrganizationSettings(organization), + "Данные организации сохранены", + ), + uploadLogo: (file: File) => runChange( + () => uploadOrganizationLogo(file), + "Логотип обновлён", + ), + removeLogo: () => runChange( + removeOrganizationLogo, + "Логотип удалён", + ), + }; +} diff --git a/apps/internal-ui/src/layout/ShellRouteContent.tsx b/apps/internal-ui/src/layout/ShellRouteContent.tsx index 2e4ed13..edd0965 100644 --- a/apps/internal-ui/src/layout/ShellRouteContent.tsx +++ b/apps/internal-ui/src/layout/ShellRouteContent.tsx @@ -17,6 +17,7 @@ import { ProductsPage } from "../features/products/ProductsPage"; import { ProductDetailPage } from "../features/products/detail/ProductDetailPage"; import { ProfilePage } from "../features/profile/ProfilePage"; import { SettingsPage } from "../features/settings/SettingsPage"; +import { AdministrationPage } from "../features/administration/AdministrationPage"; import { SalesClientDetailPage } from "../features/sales/client-detail/SalesClientDetailPage"; import { SalesClientsPage } from "../features/sales/SalesClientsPage"; import { SalesDialogsPage } from "../features/sales/SalesDialogsPage"; @@ -50,6 +51,15 @@ export function ShellRouteContent({ route, data, currentEmployee, currentProduct {route === "productDetail" && !currentProduct && } {route === "profile" && } {route === "settings" && } + {(route === "administrationOrganization" + || route === "administrationSubscription" + || route === "administrationAudit") && ( + + )} {route === "salesClients" && } {route === "salesClientDetail" && } {route === "salesOverview" && } diff --git a/apps/internal-ui/src/layout/Sidebar.tsx b/apps/internal-ui/src/layout/Sidebar.tsx index c56c546..f6f7b8e 100644 --- a/apps/internal-ui/src/layout/Sidebar.tsx +++ b/apps/internal-ui/src/layout/Sidebar.tsx @@ -49,6 +49,24 @@ const AI_ITEMS: SidebarNavSectionItem[] = [ { activeRoutes: ["aiUsage"], disabled: true, key: "aiUsage", label: "Использование AI" }, ]; +const ADMINISTRATION_ITEMS: SidebarNavSectionItem[] = [ + { + activeRoutes: ["administrationOrganization"], + key: "administrationOrganization", + label: "Организация", + }, + { + activeRoutes: ["administrationSubscription"], + key: "administrationSubscription", + label: "Тариф и оплата", + }, + { + activeRoutes: ["administrationAudit"], + key: "administrationAudit", + label: "Аудит", + }, +]; + function visibleItems(user: SessionUser, items: SidebarNavSectionItem[]) { return items.filter((item) => canAccess(user, item.key)); } @@ -64,11 +82,16 @@ export function Sidebar({ route, user, setRoute, onLogout, waitingCount = 0 }: { )); const supportItems = visibleItems(user, SUPPORT_ITEMS); const aiItems = visibleItems(user, AI_ITEMS); + const administrationItems = visibleItems(user, ADMINISTRATION_ITEMS); return ( diff --git a/apps/internal-ui/src/layout/SidebarNavSection.tsx b/apps/internal-ui/src/layout/SidebarNavSection.tsx index a185e0c..8fec83f 100644 --- a/apps/internal-ui/src/layout/SidebarNavSection.tsx +++ b/apps/internal-ui/src/layout/SidebarNavSection.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Icon } from "../shared/icons"; import type { RouteKey } from "../types"; @@ -26,14 +26,18 @@ export function SidebarNavSection({ setRoute: (route: RouteKey) => void; storageKey: string; }) { + const active = items.some((item) => item.activeRoutes.includes(route)); const [expanded, setExpanded] = useState(() => { try { - return window.localStorage.getItem(storageKey) === "true"; + return active || window.localStorage.getItem(storageKey) === "true"; } catch { - return false; + return active; } }); - const active = items.some((item) => item.activeRoutes.includes(route)); + + useEffect(() => { + if (active) setExpanded(true); + }, [active]); if (items.length === 0) return null; diff --git a/apps/internal-ui/src/layout/styles.css b/apps/internal-ui/src/layout/styles.css index a95241f..09b1507 100644 --- a/apps/internal-ui/src/layout/styles.css +++ b/apps/internal-ui/src/layout/styles.css @@ -45,6 +45,20 @@ border-radius: 8px; } +.hub-brand-mark.has-logo { + overflow: hidden; + color: inherit; + background: #ffffff; + border: 1px solid #e8e8e8; +} + +.hub-brand-mark img { + width: 100%; + height: 100%; + display: block; + object-fit: contain; +} + .hub-brand strong, .profile-link strong { display: block; diff --git a/apps/internal-ui/src/router.test.ts b/apps/internal-ui/src/router.test.ts index 506a9ad..c88b1b7 100644 --- a/apps/internal-ui/src/router.test.ts +++ b/apps/internal-ui/src/router.test.ts @@ -91,6 +91,33 @@ describe("support routes", () => { }); }); +describe("administration route", () => { + it("keeps the old URL as an organization alias", () => { + expect(routeFromPath("/administration")).toEqual({ + route: "administrationOrganization", + ...empty, + }); + }); + + it("parses and creates administration subsection URLs", () => { + expect(routeFromPath("/administration/organization")).toEqual({ + route: "administrationOrganization", + ...empty, + }); + expect(routeFromPath("/administration/subscription")).toEqual({ + route: "administrationSubscription", + ...empty, + }); + expect(routeFromPath("/administration/audit")).toEqual({ + route: "administrationAudit", + ...empty, + }); + expect(pathFromRoute("administrationOrganization")).toBe("/administration/organization"); + expect(pathFromRoute("administrationSubscription")).toBe("/administration/subscription"); + expect(pathFromRoute("administrationAudit")).toBe("/administration/audit"); + }); +}); + describe("support portal routes", () => { it("parses support portal list and detail URLs", () => { expect(routeFromPath("/departments/support/portals")).toEqual({ route: "supportPortals", ...empty }); diff --git a/apps/internal-ui/src/router.ts b/apps/internal-ui/src/router.ts index 9b06fa5..a32f5b4 100644 --- a/apps/internal-ui/src/router.ts +++ b/apps/internal-ui/src/router.ts @@ -76,6 +76,15 @@ export function routeFromPath(pathname: string, search = ""): RouteState { } if (path === "/ai/usage") return { route: "aiUsage", ...state }; if (path === "/integrations") return { route: "integrations", ...state }; + if (path === "/administration" || path === "/administration/organization") { + return { route: "administrationOrganization", ...state }; + } + if (path === "/administration/subscription") { + return { route: "administrationSubscription", ...state }; + } + if (path === "/administration/audit") { + return { route: "administrationAudit", ...state }; + } if (path === "/profile") return { route: "profile", ...state }; if (path === "/settings") return { route: "settings", ...state }; return { route: "command", ...state }; @@ -111,6 +120,9 @@ export function pathFromRoute(route: RouteKey, entityId: number | null = null, p if (route === "channelCreate") return `${prefix}/channels/new`; if (route === "channelDetail") return entityId ? `${prefix}/channels/${entityId}` : `${prefix}/channels`; if (route === "integrations") return `${prefix}/integrations`; + if (route === "administrationOrganization") return `${prefix}/administration/organization`; + if (route === "administrationSubscription") return `${prefix}/administration/subscription`; + if (route === "administrationAudit") return `${prefix}/administration/audit`; if (route === "profile") return `${prefix}/profile`; if (route === "settings") return `${prefix}/settings`; return `${prefix}/profile`; diff --git a/apps/internal-ui/src/routes.ts b/apps/internal-ui/src/routes.ts index bf252d9..54d9fa4 100644 --- a/apps/internal-ui/src/routes.ts +++ b/apps/internal-ui/src/routes.ts @@ -2,6 +2,9 @@ import type { RouteKey } from "./types"; export const routes: Record = { accessProfiles: "Профили доступа", + administrationOrganization: "Организация", + administrationSubscription: "Тариф и оплата", + administrationAudit: "Аудит", command: "Обзор", departments: "Отделы", employeeDetail: "Сотрудники", diff --git a/apps/internal-ui/src/styles.css b/apps/internal-ui/src/styles.css index 1be044a..584f937 100644 --- a/apps/internal-ui/src/styles.css +++ b/apps/internal-ui/src/styles.css @@ -36,5 +36,6 @@ @import "./features/channels/styles-sections.css"; @import "./features/channels/styles-wizard.css"; @import "./features/integrations/styles.css"; +@import "./features/administration/styles.css"; @import "./features/notifications/styles.css"; @import "./shared/state.css"; diff --git a/apps/internal-ui/src/types.ts b/apps/internal-ui/src/types.ts index 3a3b22e..fc42ed8 100644 --- a/apps/internal-ui/src/types.ts +++ b/apps/internal-ui/src/types.ts @@ -4,6 +4,7 @@ import type { AiAgent } from "./features/ai/model"; // «оператор» — рабочая функция сотрудника (EMPLOYEE) в своём отделе. export type Role = "OWNER" | "ADMIN" | "EMPLOYEE"; export type ProductStatus = "ACTIVE" | "DISABLED"; +export type DeliveryMode = "CLOUD" | "SELF_HOSTED"; export type OrganizationMembership = { id: number; @@ -12,6 +13,7 @@ export type OrganizationMembership = { positionTitle: string; organization: string; organizationName: string; + organizationLogoUrl: string | null; department: string | null; totpRequired: boolean; capabilities: string[]; @@ -24,6 +26,7 @@ export type AuthenticatedUser = { fullName: string; mustChangePassword: boolean; totpEnabled: boolean; + deliveryMode: DeliveryMode; memberships: OrganizationMembership[]; }; @@ -171,7 +174,7 @@ export type ProductOffer = { prices: ProductPrice[]; }; -export type RouteKey = "accessProfiles" | "command" | "departments" | "employeeDetail" | "employees" | "productDetail" | "products" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "salesDialogs" | "salesOrderDetail" | "salesOrders" | "salesOverview" | "supportOverview" | "supportDialogs" | "supportPortals" | "supportPortalDetail" | "aiAgents" | "aiAgentCreate" | "aiAgentDetail" | "aiKnowledge" | "aiKnowledgeCreate" | "aiKnowledgeDetail" | "aiUsage" | "integrations" | "channels" | "channelCreate" | "channelDetail"; +export type RouteKey = "accessProfiles" | "administrationOrganization" | "administrationSubscription" | "administrationAudit" | "command" | "departments" | "employeeDetail" | "employees" | "productDetail" | "products" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "salesDialogs" | "salesOrderDetail" | "salesOrders" | "salesOverview" | "supportOverview" | "supportDialogs" | "supportPortals" | "supportPortalDetail" | "aiAgents" | "aiAgentCreate" | "aiAgentDetail" | "aiKnowledge" | "aiKnowledgeCreate" | "aiKnowledgeDetail" | "aiUsage" | "integrations" | "channels" | "channelCreate" | "channelDetail"; export type AppData = { employees: Employee[]; diff --git a/compose.dev.yaml b/compose.dev.yaml index 4d2d00c..44448bd 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -21,6 +21,7 @@ services: context: . dockerfile: apps/backend/Dockerfile environment: + CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD} CUS_HELP_BASE_DOMAIN: localhost CUS_HELP_PUBLIC_SCHEME: http CUS_HELP_PUBLIC_PORT: "" @@ -35,6 +36,7 @@ services: ports: - "${BACKEND_APP_PORT:-8010}:8000" environment: + CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD} CUS_ALLOWED_HOSTS: "localhost,127.0.0.1,.localhost" CUS_HELP_BASE_DOMAIN: localhost CUS_HELP_PUBLIC_SCHEME: http @@ -57,6 +59,7 @@ services: ports: - "${BACKEND_PLATFORM_PORT:-8011}:8000" environment: + CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD} CUS_ALLOWED_HOSTS: "localhost,127.0.0.1,.localhost" CUS_HELP_BASE_DOMAIN: localhost CUS_HELP_PUBLIC_SCHEME: http @@ -69,6 +72,8 @@ services: context: . dockerfile: apps/backend/Dockerfile command: uvicorn hub_backend.asgi_admin:application --host 0.0.0.0 --port 8000 --reload + environment: + CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD} volumes: - ./apps/backend:/app/apps/backend @@ -76,6 +81,8 @@ services: build: context: . dockerfile: apps/backend/Dockerfile + environment: + CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD} volumes: - ./apps/backend:/app/apps/backend @@ -125,6 +132,7 @@ services: working_dir: /app/apps/backend environment: CUS_DB_ROLE: migration + CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD} CUSTOCRM_LOCAL_SEED: "1" volumes: - ./apps/backend:/app/apps/backend @@ -148,6 +156,7 @@ services: working_dir: /app/apps/backend environment: CUS_DB_ROLE: migration + CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD} CUSTOCRM_LOCAL_SEED: "1" CUS_HELP_BASE_DOMAIN: localhost CUS_HELP_PUBLIC_SCHEME: http diff --git a/env.example b/env.example index 0f2faa0..2757428 100644 --- a/env.example +++ b/env.example @@ -25,6 +25,8 @@ CUSTOCRM_WEB_LISTENING_IP=0.0.0.0 # --- Django core --- CUS_ENV=production CUS_DEBUG=false +# Коробочная поставка. Облачный контур использует CLOUD. +CUS_DELIVERY_MODE=SELF_HOSTED CUS_SECRET_KEY=change-me-long-random-secret # Шифрование секретов в БД (Fernet-ключ): Fernet.generate_key(). CUS_FIELD_ENCRYPTION_KEY= diff --git a/scripts/start.ps1 b/scripts/start.ps1 index 6d16695..450433a 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -1,3 +1,8 @@ +param( + [ValidateSet("Cloud", "SelfHosted")] + [string]$Mode = "Cloud" +) + $ErrorActionPreference = "Stop" if (-not (Test-Path ".env")) { @@ -5,4 +10,19 @@ if (-not (Test-Path ".env")) { } # Dev-контур: canonical compose.yaml + dev override (ADR-HUB-0028). -docker compose -f compose.yaml -f compose.dev.yaml --env-file .env.example --env-file .env up --build +# Режим поставки задаётся процессу Compose и не переписывает локальный .env. +$deliveryMode = if ($Mode -eq "SelfHosted") { "SELF_HOSTED" } else { "CLOUD" } +$previousDeliveryMode = $env:CUS_DELIVERY_MODE +$env:CUS_DELIVERY_MODE = $deliveryMode + +try { + docker compose -f compose.yaml -f compose.dev.yaml --env-file .env.example --env-file .env up --build +} +finally { + if ($null -eq $previousDeliveryMode) { + Remove-Item Env:CUS_DELIVERY_MODE -ErrorAction SilentlyContinue + } + else { + $env:CUS_DELIVERY_MODE = $previousDeliveryMode + } +}