mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
🐛 fix(portals): базовый домен порталов — адрес установки, а не localhost
Суффикс приходил из переменной окружения с умолчанием localhost, а задать её в коробке негде: у продукта нет .env, и production-манифест её не передаёт. На живой установке диалог создания портала предлагал help.localhost — адрес, который не резолвится и не получает сертификат. Источник теперь тот же, что у IPv4 для A-записи: адрес из мастера первого запуска. Переменная осталась переопределением для контуров с собственной конфигурацией. Пока установку открывают по IP, домена нет вовсе: hosted-адрес не собирается, диалог говорит об этом и не даёт создать портал вместо выдачи мёртвой ссылки. Порталы, успевшие осесть на .localhost, миграция переводит на домен установки — такие ссылки всё равно никуда не вели. Заодно поле адреса в диалоге: без color и background оно красилось системным белым и светилось в тёмной теме. Скин по базлайну — 36px, текст --n-1, суффикс на --n-9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
44718142b3
commit
42a468ec97
14 files changed
+305
-36
No files matched your search
@@ -111,6 +111,10 @@ MESSAGES: dict[str, object] = {
|
||||
"portals.invalid_revision": "Invalid revision",
|
||||
"portals.invalid_section": "Invalid section",
|
||||
"portals.not_found": "Portal not found",
|
||||
"portals.installation_domain_missing": (
|
||||
"The installation address has no domain: set one in Settings, "
|
||||
"otherwise there is nowhere to host the portal"
|
||||
),
|
||||
"portals.section_not_found": "Section not found",
|
||||
"profile.accent_hex": "The accent is a HEX colour like #0f9b8e",
|
||||
"profile.choose_photo_file": "Choose a photo file",
|
||||
|
||||
@@ -115,6 +115,10 @@ MESSAGES: dict[str, object] = {
|
||||
"portals.invalid_revision": "Некорректная версия",
|
||||
"portals.invalid_section": "Некорректный раздел",
|
||||
"portals.not_found": "Портал не найден",
|
||||
"portals.installation_domain_missing": (
|
||||
"Адрес установки задан без домена: укажите домен в «Настройках», "
|
||||
"иначе портал негде разместить"
|
||||
),
|
||||
"portals.section_not_found": "Раздел не найден",
|
||||
"profile.accent_hex": "Акцент — HEX-цвет вида #0f9b8e",
|
||||
"profile.choose_photo_file": "Выберите файл фото",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -35,8 +36,60 @@ def _installation_hosts() -> tuple[str, ...]:
|
||||
return ()
|
||||
|
||||
|
||||
def _is_ip_literal(value: str) -> bool:
|
||||
"""IPv4 проходит проверку домена по буквам меток, а доменом не является."""
|
||||
|
||||
try:
|
||||
IPv4Address(value)
|
||||
except ValueError:
|
||||
return ":" in value
|
||||
return True
|
||||
|
||||
|
||||
def base_domain_for(installation_host: str) -> str:
|
||||
"""Базовый домен при таком адресе установки; пусто — домена ещё нет.
|
||||
|
||||
Отдельно от ``help_base_domain`` ради миграции, которая читает адрес через
|
||||
историческую модель настроек, а не через рантайм-модель.
|
||||
"""
|
||||
|
||||
configured = normalize_domain(
|
||||
str(getattr(settings, "CHATBALLS_HELP_BASE_DOMAIN", "") or "")
|
||||
)
|
||||
if configured:
|
||||
return configured
|
||||
host = normalize_domain(installation_host)
|
||||
if not host or _is_ip_literal(host):
|
||||
return ""
|
||||
try:
|
||||
return validate_domain(host)
|
||||
except ValidationError:
|
||||
return ""
|
||||
|
||||
|
||||
def help_base_domain() -> str:
|
||||
"""Базовый домен порталов помощи; пусто — установка ещё не знает своего домена.
|
||||
|
||||
Штатный источник — адрес самой установки: у коробки нет .env, и переменную
|
||||
окружения задать негде, а её прежнее значение по умолчанию (``localhost``)
|
||||
превращало адрес портала на живом домене в ``help.localhost``. Переменная
|
||||
остаётся переопределением для контуров, которые ведут конфигурацию сами
|
||||
(dev-стек, staging) — там же, где она задаётся у IPv4 (``public_address``).
|
||||
"""
|
||||
|
||||
from chatballs.identity.instance_settings import public_host
|
||||
|
||||
try:
|
||||
host = public_host()
|
||||
except Exception: # таблицы ещё нет (ранние миграции)
|
||||
host = ""
|
||||
return base_domain_for(host)
|
||||
|
||||
|
||||
def hosted_domain(portal_key: str) -> str:
|
||||
base_domain = normalize_domain(settings.CHATBALLS_HELP_BASE_DOMAIN)
|
||||
base_domain = help_base_domain()
|
||||
if not base_domain:
|
||||
raise ValidationError(t("portals.installation_domain_missing"))
|
||||
return validate_domain(f"{portal_key}.{base_domain}")
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ from django.db.models import Q
|
||||
|
||||
def fill_hosted_domains(apps, schema_editor):
|
||||
SupportPortal = apps.get_model("support_portals", "SupportPortal")
|
||||
base_domain = settings.CHATBALLS_HELP_BASE_DOMAIN.strip().lower().rstrip(".")
|
||||
# Историческая засыпка: до этой миграции базовый домен приходил только из
|
||||
# переменной окружения, и её умолчанием был localhost. Оно и остаётся здесь,
|
||||
# чтобы бэкфилл старых строк не зависел от того, знает ли установка свой
|
||||
# домен сейчас; актуальные адреса считает addressing.help_base_domain.
|
||||
base_domain = settings.CHATBALLS_HELP_BASE_DOMAIN.strip().lower().rstrip(".") or "localhost"
|
||||
for portal in SupportPortal.objects.all().only("id", "slug"):
|
||||
SupportPortal.objects.filter(id=portal.id).update(
|
||||
hosted_domain=f"{portal.slug}.{base_domain}"
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
"""Порталы, оставшиеся на `.localhost`, переезжают на домен установки.
|
||||
|
||||
До появления `addressing.help_base_domain` базовый домен приходил только из
|
||||
переменной окружения, а задать её в коробке негде: у продукта нет .env. Поэтому
|
||||
на реальной установке адрес портала складывался как `help.localhost` — снаружи
|
||||
он не вёл никуда и сертификата не получал. Теперь домен известен из мастера
|
||||
первого запуска, и такие адреса пора привести к нему.
|
||||
|
||||
Переписываются только суффиксы `.localhost`: ссылки, которые всё равно не
|
||||
работали. Всё остальное — в том числе dev-стек и staging с явной переменной —
|
||||
остаётся как есть: там смена адреса ломала бы живые ссылки на статьи.
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def move_hosted_domains_to_installation(apps, schema_editor):
|
||||
from chatballs.support_portals.addressing import base_domain_for
|
||||
|
||||
InstanceSettings = apps.get_model("identity", "InstanceSettings")
|
||||
host = (
|
||||
InstanceSettings.objects.filter(pk=1)
|
||||
.values_list("public_host", flat=True)
|
||||
.first()
|
||||
or ""
|
||||
)
|
||||
base_domain = base_domain_for(host)
|
||||
if not base_domain or base_domain == "localhost":
|
||||
return
|
||||
|
||||
SupportPortal = apps.get_model("support_portals", "SupportPortal")
|
||||
portals = SupportPortal.objects.filter(hosted_domain__endswith=".localhost")
|
||||
for portal in portals.only("id", "slug", "hosted_domain"):
|
||||
moved = f"{portal.slug}.{base_domain}"
|
||||
taken = SupportPortal.objects.exclude(id=portal.id).filter(hosted_domain=moved)
|
||||
if taken.exists():
|
||||
continue
|
||||
SupportPortal.objects.filter(id=portal.id).update(hosted_domain=moved)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("support_portals", "0010_article_files"),
|
||||
# Домен установки живёт в настройках инстанса: без этой таблицы его
|
||||
# неоткуда прочитать.
|
||||
("identity", "0033_instance_previous_public_host"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
move_hosted_domains_to_installation,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@@ -9,6 +9,7 @@ from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.integrations.models import IntegrationProvider, IntegrationStatus
|
||||
from chatballs.support_portals.addressing import help_base_domain
|
||||
from chatballs.support_portals.api import validation_response
|
||||
from chatballs.support_portals.domain_services import (
|
||||
set_custom_domain,
|
||||
@@ -147,7 +148,7 @@ class PortalListView(PortalBaseView):
|
||||
},
|
||||
"address": {
|
||||
"scheme": settings.CHATBALLS_HELP_PUBLIC_SCHEME,
|
||||
"baseDomain": settings.CHATBALLS_HELP_BASE_DOMAIN,
|
||||
"baseDomain": help_base_domain(),
|
||||
"port": settings.CHATBALLS_HELP_PUBLIC_PORT or None,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Базовый домен порталов берётся из адреса установки.
|
||||
|
||||
Переменную окружения в коробке задавать негде (у продукта нет .env), а её
|
||||
прежнее умолчание — localhost — оставляло портал на help.localhost даже там,
|
||||
где установка работает на живом домене: в диалоге создания владелец видел
|
||||
суффикс, которого у него нет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import apps as django_apps
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from chatballs.identity.instance_settings import InstanceSettings, invalidate_cache
|
||||
from chatballs.support_portals.addressing import help_base_domain, hosted_domain
|
||||
from chatballs.support_portals.models import SupportPortal
|
||||
from chatballs.support_portals.tests.base import SupportPortalTestCase
|
||||
|
||||
|
||||
class HelpBaseDomainTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
invalidate_cache()
|
||||
self.addCleanup(invalidate_cache)
|
||||
|
||||
def _set_host(self, host: str) -> None:
|
||||
row = InstanceSettings.load()
|
||||
row.public_host = host
|
||||
row.save(update_fields=["public_host", "updated_at"])
|
||||
invalidate_cache()
|
||||
|
||||
@override_settings(CHATBALLS_HELP_BASE_DOMAIN="")
|
||||
def test_installation_domain_becomes_base_domain(self) -> None:
|
||||
self._set_host("crm.example.test")
|
||||
|
||||
self.assertEqual(help_base_domain(), "crm.example.test")
|
||||
self.assertEqual(hosted_domain("help"), "help.crm.example.test")
|
||||
|
||||
@override_settings(CHATBALLS_HELP_BASE_DOMAIN="help.example.test")
|
||||
def test_environment_variable_overrides_installation_address(self) -> None:
|
||||
self._set_host("crm.example.test")
|
||||
|
||||
self.assertEqual(help_base_domain(), "help.example.test")
|
||||
|
||||
@override_settings(CHATBALLS_HELP_BASE_DOMAIN="")
|
||||
def test_installation_known_only_by_ip_has_no_base_domain(self) -> None:
|
||||
self._set_host("203.0.113.10")
|
||||
|
||||
self.assertEqual(help_base_domain(), "")
|
||||
with self.assertRaises(ValidationError):
|
||||
hosted_domain("help")
|
||||
|
||||
|
||||
class PortalCreationWithoutDomainTests(SupportPortalTestCase):
|
||||
"""Пока установка известна только по IP, размещать портал негде."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
invalidate_cache()
|
||||
self.addCleanup(invalidate_cache)
|
||||
row = InstanceSettings.load()
|
||||
row.public_host = "203.0.113.10"
|
||||
row.save(update_fields=["public_host", "updated_at"])
|
||||
invalidate_cache()
|
||||
|
||||
@override_settings(CHATBALLS_HELP_BASE_DOMAIN="")
|
||||
def test_list_reports_empty_base_domain(self) -> None:
|
||||
response = self.client.get("/api/v1/support/portals/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["address"]["baseDomain"], "")
|
||||
|
||||
@override_settings(CHATBALLS_HELP_BASE_DOMAIN="")
|
||||
def test_creation_is_rejected(self) -> None:
|
||||
response = self.create_portal()
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
class HostedDomainMigrationTests(SupportPortalTestCase):
|
||||
"""Порталы, созданные до появления домена, переезжают на него миграцией."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
invalidate_cache()
|
||||
self.addCleanup(invalidate_cache)
|
||||
self.migration = import_module(
|
||||
"chatballs.support_portals.migrations"
|
||||
".0011_hosted_domains_follow_installation"
|
||||
)
|
||||
|
||||
def _set_host(self, host: str) -> None:
|
||||
row = InstanceSettings.load()
|
||||
row.public_host = host
|
||||
row.save(update_fields=["public_host", "updated_at"])
|
||||
invalidate_cache()
|
||||
|
||||
def test_localhost_address_moves_to_the_installation_domain(self) -> None:
|
||||
self.assertEqual(self.create_portal().status_code, 201)
|
||||
portal = SupportPortal.objects.get(slug="app-help")
|
||||
self.assertEqual(portal.hosted_domain, "app-help.localhost")
|
||||
self._set_host("crm.example.test")
|
||||
|
||||
with override_settings(CHATBALLS_HELP_BASE_DOMAIN=""):
|
||||
self.migration.move_hosted_domains_to_installation(django_apps, None)
|
||||
|
||||
portal.refresh_from_db()
|
||||
self.assertEqual(portal.hosted_domain, "app-help.crm.example.test")
|
||||
|
||||
def test_installation_without_domain_leaves_addresses_alone(self) -> None:
|
||||
self.assertEqual(self.create_portal().status_code, 201)
|
||||
self._set_host("203.0.113.10")
|
||||
|
||||
with override_settings(CHATBALLS_HELP_BASE_DOMAIN=""):
|
||||
self.migration.move_hosted_domains_to_installation(django_apps, None)
|
||||
|
||||
portal = SupportPortal.objects.get(slug="app-help")
|
||||
self.assertEqual(portal.hosted_domain, "app-help.localhost")
|
||||
@@ -15,9 +15,14 @@ CHATBALLS_APP_PRIMARY_HOSTS = env_list(
|
||||
"CHATBALLS_APP_ALLOWED_HOSTS",
|
||||
env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1", "app.localhost"]),
|
||||
)
|
||||
_help_host_pattern = f".{CHATBALLS_HELP_BASE_DOMAIN}"
|
||||
if _help_host_pattern not in CHATBALLS_APP_PRIMARY_HOSTS:
|
||||
CHATBALLS_APP_PRIMARY_HOSTS.append(_help_host_pattern)
|
||||
# Wildcard базового домена — только когда его задали переменной окружения. При
|
||||
# штатном источнике (адрес установки) домен известен лишь в рантайме, а хосты
|
||||
# порталов и без списка проходят: их проверяет каталог порталов в
|
||||
# SupportPortalHostBoundaryMiddleware.
|
||||
if CHATBALLS_HELP_BASE_DOMAIN:
|
||||
_help_host_pattern = f".{CHATBALLS_HELP_BASE_DOMAIN}"
|
||||
if _help_host_pattern not in CHATBALLS_APP_PRIMARY_HOSTS:
|
||||
CHATBALLS_APP_PRIMARY_HOSTS.append(_help_host_pattern)
|
||||
if TESTING and "testserver" not in CHATBALLS_APP_PRIMARY_HOSTS:
|
||||
CHATBALLS_APP_PRIMARY_HOSTS.append("testserver")
|
||||
# Custom portal domains are checked against the published ingress directory by
|
||||
|
||||
@@ -261,12 +261,18 @@ CHATBALLS_STORAGE_BACKEND, MEDIA_ROOT, STORAGES = build_storage_settings(
|
||||
# Публичный адрес Hub: абсолютные ссылки, уходящие клиентам (download вложений).
|
||||
CHATBALLS_PUBLIC_BASE_URL = os.environ.get("CHATBALLS_PUBLIC_BASE_URL", "http://localhost:8000")
|
||||
|
||||
# Публичные порталы поддержки размещаются на отдельных хостах.
|
||||
# Базовый домен порталов помощи задаёт установщик (CHATBALLS_HELP_BASE_DOMAIN);
|
||||
# по умолчанию — localhost, никаких зашитых доменов.
|
||||
# Публичные порталы поддержки размещаются на отдельных хостах. Базовый домен
|
||||
# берётся из адреса самой установки (support_portals.addressing.help_base_domain):
|
||||
# у коробки нет .env, задать переменную окружения негде, и её прежнее значение
|
||||
# по умолчанию — localhost — оставляло порталы на help.localhost даже на живом
|
||||
# домене. Пустое значение здесь — не «localhost», а «источник штатный»;
|
||||
# переменная остаётся переопределением для контуров с собственной конфигурацией.
|
||||
# В тестах адреса установки нет (мастер не проходили), а порталы нужны почти
|
||||
# каждому набору: там суффикс остаётся localhost, как в dev-стеке. Сам штатный
|
||||
# путь проверяется отдельно (support_portals/tests/test_base_domain.py).
|
||||
CHATBALLS_HELP_BASE_DOMAIN = os.environ.get(
|
||||
"CHATBALLS_HELP_BASE_DOMAIN",
|
||||
"localhost",
|
||||
"localhost" if TESTING else "",
|
||||
).strip().lower().rstrip(".")
|
||||
CHATBALLS_HELP_PUBLIC_SCHEME = os.environ.get("CHATBALLS_HELP_PUBLIC_SCHEME", "https").strip().lower()
|
||||
CHATBALLS_HELP_PUBLIC_PORT = os.environ.get("CHATBALLS_HELP_PUBLIC_PORT", "").strip()
|
||||
|
||||
@@ -41,7 +41,10 @@ export function PortalBasicsSettings({
|
||||
}, [portal]);
|
||||
|
||||
const port = address.port ? `:${address.port}` : "";
|
||||
const finalUrl = `${address.scheme}://${slug || portal.slug}.${address.baseDomain}${port}/`;
|
||||
// Если установка сейчас не знает своего домена (её открыли по IP), суффикс
|
||||
// всё равно есть — он записан в hosted-адресе портала при создании.
|
||||
const baseDomain = address.baseDomain || portal.hostedDomain.split(".").slice(1).join(".");
|
||||
const finalUrl = `${address.scheme}://${slug || portal.slug}.${baseDomain}${port}/`;
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
@@ -81,9 +84,9 @@ export function PortalBasicsSettings({
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value.toLocaleLowerCase())}
|
||||
/>
|
||||
<b>.{address.baseDomain}</b>
|
||||
<b>.{baseDomain}</b>
|
||||
</span>
|
||||
<small>{t("portals.lowercase_latin_letters_digits_hyphens")}<b>{address.baseDomain}</b> {t("portals.base_domain_hint")}
|
||||
<small>{t("portals.lowercase_latin_letters_digits_hyphens")}<b>{baseDomain}</b> {t("portals.base_domain_hint")}
|
||||
</small>
|
||||
{fieldErrors.slug && <small className="portal-field-error">{fieldErrors.slug}</small>}
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,10 @@ export function PortalCreateDialog({
|
||||
const [error, setError] = useState("");
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const portalKeyValid = validPortalKey(slug.trim());
|
||||
// Базовый домен — адрес самой установки; пока она известна только по IP,
|
||||
// размещать портал негде, и честнее сказать это здесь, чем выдать адрес,
|
||||
// который никуда не ведёт.
|
||||
const baseDomainKnown = Boolean(address.baseDomain);
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
@@ -73,24 +77,28 @@ export function PortalCreateDialog({
|
||||
<div className="portal-dialog-form">
|
||||
<p>{t("portals.portal_public_help_centre_with")}</p>
|
||||
<FormField error={fieldErrors.name} label={t("portals.portal_name")} value={name} onChange={setName} placeholder={t("portals.example_help_centre")} wide />
|
||||
<label className={`portal-address-field${fieldErrors.slug ? " is-invalid" : ""}`}>
|
||||
<span>{t("portals.portal_address")}</span>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value.toLocaleLowerCase())}
|
||||
placeholder="help"
|
||||
autoComplete="off"
|
||||
maxLength={63}
|
||||
pattern="[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
|
||||
/>
|
||||
<b>.{address.baseDomain}</b>
|
||||
</div>
|
||||
<small>{t("portals.use_lowercase_latin_letters_digits")}</small>
|
||||
{fieldErrors.slug && <small className="form-field-error" role="alert">{fieldErrors.slug}</small>}
|
||||
{slug.trim() && <code>{configuredAddress(address, slug.trim())}</code>}
|
||||
</label>
|
||||
{baseDomainKnown ? (
|
||||
<label className={`portal-address-field${fieldErrors.slug ? " is-invalid" : ""}`}>
|
||||
<span>{t("portals.portal_address")}</span>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value.toLocaleLowerCase())}
|
||||
placeholder="help"
|
||||
autoComplete="off"
|
||||
maxLength={63}
|
||||
pattern="[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
|
||||
/>
|
||||
<b>.{address.baseDomain}</b>
|
||||
</div>
|
||||
<small>{t("portals.use_lowercase_latin_letters_digits")}</small>
|
||||
{fieldErrors.slug && <small className="form-field-error" role="alert">{fieldErrors.slug}</small>}
|
||||
{slug.trim() && <code>{configuredAddress(address, slug.trim())}</code>}
|
||||
</label>
|
||||
) : (
|
||||
<div className="portal-form-error" role="alert">{t("portals.installation_domain_missing")}</div>
|
||||
)}
|
||||
<SelectField
|
||||
label={t("portals.primary_language")}
|
||||
value={locale}
|
||||
@@ -100,7 +108,7 @@ export function PortalCreateDialog({
|
||||
{error && <div className="portal-form-error">{error}</div>}
|
||||
<div className="portal-dialog-actions">
|
||||
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button variant="primary" disabled={busy || !name.trim() || !portalKeyValid} onClick={() => void submit()}>
|
||||
<Button variant="primary" disabled={busy || !baseDomainKnown || !name.trim() || !portalKeyValid} onClick={() => void submit()}>
|
||||
{busy ? t("portals.creating") : t("portals.create_portal")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -359,7 +359,7 @@
|
||||
|
||||
.portal-address-field > div {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -374,7 +374,12 @@
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
/* Скин поля — из базлайна (bg подложки, текст --n-1): без явных цветов поле
|
||||
красилось системным белым и в тёмной теме светилось. */
|
||||
background: transparent;
|
||||
color: var(--n-1);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.portal-address-field b {
|
||||
@@ -382,10 +387,10 @@
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
border-left: 1px solid var(--border-input);
|
||||
background: var(--n-10);
|
||||
color: var(--text-secondary);
|
||||
background: var(--n-9);
|
||||
color: var(--n-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
@@ -878,6 +878,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"portals.import_articles_from_yaml": "Import articles from YAML",
|
||||
"portals.importing": "Importing…",
|
||||
"portals.insert_image": "Insert an image",
|
||||
"portals.installation_domain_missing": "The installation address has no domain — there is nowhere to host a portal. Set the installation domain in Settings, then come back.",
|
||||
"portals.insert_into_text": "Insert into the text",
|
||||
"portals.inserted_into_text": "· inserted into the text",
|
||||
"portals.invalid_yaml": "Invalid YAML",
|
||||
|
||||
@@ -879,6 +879,7 @@ export const ru = {
|
||||
"portals.import_articles_from_yaml": "Импорт статей из YAML",
|
||||
"portals.importing": "Импорт…",
|
||||
"portals.insert_image": "Вставить изображение",
|
||||
"portals.installation_domain_missing": "Адрес установки задан без домена — размещать портал негде. Укажите домен установки в «Настройках», затем вернитесь сюда.",
|
||||
"portals.insert_into_text": "Вставить в текст",
|
||||
"portals.inserted_into_text": "· вставлен в текст",
|
||||
"portals.invalid_yaml": "Невалидный YAML",
|
||||
|
||||
Reference in new issue
Block a user