From eaee6a9f7b448bc1be36cf4b2ad36e9a7a50dfe5 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 8 Sep 2026 01:18:39 +0300 Subject: [PATCH] =?UTF-8?q?:lipstick:=20refactor(settings):=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B4=D0=B5=D0=BB=20=C2=AB=D0=9F=D0=BB=D0=B0=D1=82=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D0=B0=C2=BB=20=E2=80=94=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B8=D0=BD=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BB=D1=8F=D1=86=D0=B8=D0=B8=20=D0=B2=D0=BE=D0=BD?= =?UTF-8?q?=20=D0=B8=D0=B7=20=C2=AB=D0=9E=D1=80=D0=B3=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Адрес установки, SMTP и TURN приехали в настройки с 41a4b0d и были приклеены к разделу «Организация» второй формой — без прохода по дизайну. Кадр N1 базлайна такой формы не содержит, лид раздела («название, логотип и региональные параметры») её не описывает, а по высоте вложение было втрое больше того, во что вложено: 977px против 358px. Разложено по смыслу: - новый раздел субменю «Платформа» (иконка globe) — последним перед «Демо-данными», решение владельца. Внутри две отдельные карточки, «Адрес установки» и «Исходящая почта», у каждой свой «Сохранить»; «Отправить тестовое письмо» переехало в карточку почты; - TURN — в «Голосовые и звонки»: включать звонки и чинить их прохождение через строгий NAT это одна задача; - «Организация» вернулась ровно к кадру N1 — одна карточка. Бэкенд не тронут: PATCH .../administration/instance/ уже частичный (блоки email и turn применяются, только если пришли в теле; publicHost/publicScheme подставляются из текущей строки), поэтому три карточки в двух разделах делят один эндпоинт и не затирают друг друга. Общий доступ вынесен в instance.ts, InstanceAddressCard удалён. Вёрстка — измерено на копии раздела с полным CSS проекта: - зазор между соседними карточками 0px -> 18px (рамки складывались в двойную линию), заголовок раздела -> первая карточка остался 22px как в N1; - подзаголовок «TURN» стоял вплотную к полю «Отправитель» (0px), а «Исходящая почта» — на 18px: теперь у каждого блока своя карточка; - .settings-storage-head -> .settings-card-head: это общий стандарт заголовка карточки раздела (N6), а не хранилищный. Обновлён и StorageSettingsCard; - TLS переведён с сегмент-контрола темы (класс профиля .appearance-theme-options) на SwitchButton — тот же стандарт, что у точек входа в «Голосовых и звонках»; - ряды действий приведены к N1: метка «Сохранено» слева, primary справа. Была обратная раскладка, а метка через .settings-section-note съезжала на 6.5px ниже оси кнопок; - на мобильном липкий ряд .administration-actions отключается, если в разделе больше одной карточки: он висел бы под всеми, сохраняя свою, а отрицательный margin вылезал за карточку и накрывал соседнюю; - TextAreaField научился disabled — поле адресов TURN было единственным, что редактировалось без прав. У «Платформы» нет кадра в базлайне (там ровно N1–N7) — собрана по стандарту N6. Проверено: tsc + vitest 77 OK. Живой экран не открывался — вход под паролем, геометрия снята с копии раздела на реальном CSS. Co-Authored-By: Claude Opus 5 --- .../src/features/settings/CallsRelayCard.tsx | 109 ++++++ .../settings/CommunicationSettingsCard.tsx | 4 + .../features/settings/InstanceAddressCard.tsx | 311 ------------------ .../settings/PlatformSettingsCard.tsx | 308 +++++++++++++++++ .../src/features/settings/SettingsPage.tsx | 36 +- .../features/settings/StorageSettingsCard.tsx | 2 +- .../src/features/settings/instance.ts | 53 +++ .../src/features/settings/sections.ts | 10 + .../src/features/settings/styles.css | 44 ++- apps/internal-ui/src/shared/form-controls.tsx | 7 +- 10 files changed, 547 insertions(+), 337 deletions(-) create mode 100644 apps/internal-ui/src/features/settings/CallsRelayCard.tsx delete mode 100644 apps/internal-ui/src/features/settings/InstanceAddressCard.tsx create mode 100644 apps/internal-ui/src/features/settings/PlatformSettingsCard.tsx create mode 100644 apps/internal-ui/src/features/settings/instance.ts diff --git a/apps/internal-ui/src/features/settings/CallsRelayCard.tsx b/apps/internal-ui/src/features/settings/CallsRelayCard.tsx new file mode 100644 index 0000000..bea104e --- /dev/null +++ b/apps/internal-ui/src/features/settings/CallsRelayCard.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState, type FormEvent } from "react"; + +import { FormField, TextAreaField } from "../../shared/form-controls"; +import { Button } from "../../shared/ui-controls"; +import { shortDateTime } from "../../shared/utils"; +import { instanceError, loadInstance, patchInstance, type InstancePayload } from "./instance"; + +// Relay для звонков (раздел «Голосовые и звонки»): TURN нужен, только когда +// прямое ICE-соединение не проходит — сети со строгим NAT. Настройка живёт +// рядом с выключателями самих звонков, а не в «Организации»: включать звонки и +// чинить их прохождение — одна задача. Общий секрет с coturn лежит в томе +// секретов и наружу не отдаётся — вводить его человеку не нужно. + +export function CallsRelayCard({ canManage }: { canManage: boolean }) { + const [current, setCurrent] = useState(null); + const [urls, setUrls] = useState(""); + const [ttl, setTtl] = useState("3600"); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + const [errorText, setErrorText] = useState(""); + const [fieldErrors, setFieldErrors] = useState>({}); + + function apply(payload: InstancePayload) { + setCurrent(payload); + setUrls(payload.turn.urls.join("\n")); + setTtl(String(payload.turn.ttlSeconds || 3600)); + } + + useEffect(() => { + loadInstance().then(apply).catch(() => setErrorText("Не удалось загрузить настройки relay")); + }, []); + + if (!current) return null; + + const touch = () => { setFieldErrors({}); setMessage(""); setErrorText(""); }; + + async function save() { + setBusy(true); + setMessage(""); + setErrorText(""); + setFieldErrors({}); + try { + const payload = await patchInstance({ + turn: { + urls: urls.split("\n").map((line) => line.trim()).filter(Boolean), + ttlSeconds: Number(ttl) || 3600, + }, + }); + apply(payload); + setMessage("Сохранено. Звонки будут использовать эти адреса relay."); + } catch (error) { + const { detail, errors } = instanceError(error); + setErrorText(detail); + setFieldErrors(errors); + } finally { + setBusy(false); + } + } + + function submit(event: FormEvent) { + event.preventDefault(); + void save(); + } + + return ( +
+
+
+ TURN для звонков + Relay на случай, когда прямое соединение не проходит +
+
+

+ Нужен, только если звонки идут через сети со строгим NAT. Общий секрет с сервером + relay {current.turn.secretReady + ? "уже создан установкой — вводить его не нужно." + : "будет создан при следующем запуске стека."} +

+
+ { setUrls(value); touch(); }} + /> + { setTtl(value); touch(); }} + /> +
+ {errorText &&
{errorText}
} + {message &&
{message}
} + {canManage && ( +
+ + {current.updatedAt ? `Сохранено ${shortDateTime(current.updatedAt)}` : "Ещё не сохранялось"} + + +
+ )} +
+ ); +} diff --git a/apps/internal-ui/src/features/settings/CommunicationSettingsCard.tsx b/apps/internal-ui/src/features/settings/CommunicationSettingsCard.tsx index b547ee8..75d3f62 100644 --- a/apps/internal-ui/src/features/settings/CommunicationSettingsCard.tsx +++ b/apps/internal-ui/src/features/settings/CommunicationSettingsCard.tsx @@ -3,10 +3,13 @@ import { useEffect, useState } from "react"; import { api } from "../../api/client"; import { ChannelGlyph } from "../../shared/badges"; import { SwitchButton } from "../../shared/form-controls"; +import { CallsRelayCard } from "./CallsRelayCard"; // «Голосовые и звонки» (Настройки): матрица точек входа × функции. Что // разрешено клиенту и сотруднику в диалогах через каждую интеграцию — // голосовые сообщения, аудио- и видеозвонки. Почта звонки не поддерживает. +// Ниже матрицы — relay TURN: включать звонки и чинить их прохождение через +// строгий NAT это одна задача, поэтому настройка живёт здесь же. type EntryPoint = { id: number; @@ -81,6 +84,7 @@ export function CommunicationSettingsCard({ canManage }: { canManage: boolean }) {items.length > 0 && (

Голосовые, присланные клиентом из мессенджера, принимаются всегда. Почта звонки не поддерживает. Изменения применяются сразу.

)} + ); } diff --git a/apps/internal-ui/src/features/settings/InstanceAddressCard.tsx b/apps/internal-ui/src/features/settings/InstanceAddressCard.tsx deleted file mode 100644 index 19c1473..0000000 --- a/apps/internal-ui/src/features/settings/InstanceAddressCard.tsx +++ /dev/null @@ -1,311 +0,0 @@ -import { useEffect, useState, type FormEvent } from "react"; - -import { api } from "../../api/client"; -import { FormField, TextAreaField } from "../../shared/form-controls"; -import { Button } from "../../shared/ui-controls"; -import { shortDateTime } from "../../shared/utils"; - -// «Адрес установки и почта» (Настройки). Адрес запомнил мастер первого запуска — -// по нему человек и зашёл, поднимая докер на сервере; здесь его меняют, когда -// завели домен и поставили TLS. Из него строятся внешние ссылки: вложения знаний -// и файлы статей уходят клиенту в мессенджер, и «localhost» там мёртв. -// Почта — тем же местом: без неё нельзя ни пригласить сотрудника, ни сбросить -// пароль, а задавать её в переменных окружения больше негде. - -type EmailPayload = { - host: string; - port: number; - user: string; - hasPassword: boolean; - useTls: boolean; - from: string; - configured: boolean; -}; - -type InstancePayload = { - publicHost: string; - publicScheme: "http" | "https"; - publicUrl: string; - updatedAt: string | null; - email: EmailPayload; - turn: { urls: string[]; ttlSeconds: number; secretReady: boolean }; -}; - -type EmailDraft = { - host: string; - port: string; - user: string; - password: string; - useTls: boolean; - from: string; -}; - -const BASE = "/api/v1/company/administration/instance/"; - -function emailDraftOf(email: EmailPayload): EmailDraft { - return { - host: email.host, - port: String(email.port || 587), - user: email.user, - password: "", - useTls: email.useTls, - from: email.from, - }; -} - -function errorMessage(error: unknown): { detail: string; errors: Record } { - if (error && typeof error === "object" && "payload" in error) { - const payload = (error as { payload?: { detail?: string; errors?: Record } }).payload; - return { detail: payload?.detail ?? "Не удалось сохранить", errors: payload?.errors ?? {} }; - } - return { detail: error instanceof Error ? error.message : "Не удалось сохранить", errors: {} }; -} - -export function InstanceAddressCard({ canManage }: { canManage: boolean }) { - const [current, setCurrent] = useState(null); - const [host, setHost] = useState(""); - const [scheme, setScheme] = useState<"http" | "https">("http"); - const [email, setEmail] = useState(null); - const [turnUrls, setTurnUrls] = useState(""); - const [turnTtl, setTurnTtl] = useState("3600"); - const [busy, setBusy] = useState<"" | "save" | "check">(""); - const [message, setMessage] = useState(""); - const [errorText, setErrorText] = useState(""); - const [fieldErrors, setFieldErrors] = useState>({}); - - function apply(payload: InstancePayload) { - setCurrent(payload); - setHost(payload.publicHost); - setScheme(payload.publicScheme); - setEmail(emailDraftOf(payload.email)); - setTurnUrls(payload.turn.urls.join("\n")); - setTurnTtl(String(payload.turn.ttlSeconds || 3600)); - } - - useEffect(() => { - api<{ instance: InstancePayload }>(BASE) - .then((payload) => apply(payload.instance)) - .catch(() => setErrorText("Не удалось загрузить настройки установки")); - }, []); - - if (!current || !email) return null; - - const touch = () => { setFieldErrors({}); setMessage(""); }; - const setEmailField = (key: keyof EmailDraft) => (value: string) => { - setEmail({ ...email, [key]: value }); - touch(); - }; - - async function save() { - if (!email) return; - setBusy("save"); - setMessage(""); - setErrorText(""); - setFieldErrors({}); - try { - const payload = await api<{ instance: InstancePayload }>(BASE, { - method: "PATCH", - body: JSON.stringify({ - publicHost: host, - publicScheme: scheme, - email: { - host: email.host, - port: Number(email.port) || 587, - user: email.user, - password: email.password, - useTls: email.useTls, - from: email.from, - }, - turn: { - urls: turnUrls.split("\n").map((line) => line.trim()).filter(Boolean), - ttlSeconds: Number(turnTtl) || 3600, - }, - }), - }); - apply(payload.instance); - setMessage("Сохранено. Ссылки и письма пойдут по этим настройкам."); - } catch (error) { - const { detail, errors } = errorMessage(error); - setErrorText(detail); - setFieldErrors(errors); - } finally { - setBusy(""); - } - } - - async function check() { - setBusy("check"); - setMessage(""); - setErrorText(""); - try { - const payload = await api<{ sent: string }>(`${BASE}email-check/`, { method: "POST", body: "{}" }); - setMessage(`Письмо отправлено на ${payload.sent}. Не пришло — проверьте папку «Спам».`); - } catch (error) { - setErrorText(errorMessage(error).detail); - } finally { - setBusy(""); - } - } - - function submit(event: FormEvent) { - event.preventDefault(); - void save(); - } - - return ( -
-
-
- Адрес установки - По нему открывают систему и по нему строятся ссылки на файлы -
-
- {(["http", "https"] as const).map((value) => ( - - ))} -
-
-

- Пока домена нет, здесь стоит адрес сервера — тот, на котором вы прошли первый запуск. - Заведёте домен и поставите перед установкой TLS — впишите его и переключите на https. -

-
- { setHost(value); touch(); }} - /> -
-

- Ссылки будут вида {scheme}://{host || "адрес"}/… -

- -
-
- Исходящая почта - Приглашения сотрудникам и сброс пароля -
-
- {([[true, "TLS"], [false, "без TLS"]] as Array<[boolean, string]>).map(([value, label]) => ( - - ))} -
-
- {!current.email.configured && ( -

- Почта не настроена: письма никуда не уходят, пригласить сотрудника не получится. -

- )} -
- - - - - -
- -
-
- TURN для звонков - Relay на случай, когда прямое соединение не проходит -
-
-

- Нужен, только если звонки идут через сети со строгим NAT. Общий секрет с сервером - relay {current.turn.secretReady ? "уже создан установкой — вводить его не нужно." : "будет создан при следующем запуске стека."} -

-
- { setTurnUrls(value); touch(); }} - /> - { setTurnTtl(value); touch(); }} - /> -
- - {errorText &&
{errorText}
} - {message &&
{message}
} - {canManage && ( -
- - {current.email.configured && ( - - )} - {current.updatedAt && ( - сохранено {shortDateTime(current.updatedAt)} - )} -
- )} -
- ); -} diff --git a/apps/internal-ui/src/features/settings/PlatformSettingsCard.tsx b/apps/internal-ui/src/features/settings/PlatformSettingsCard.tsx new file mode 100644 index 0000000..baec683 --- /dev/null +++ b/apps/internal-ui/src/features/settings/PlatformSettingsCard.tsx @@ -0,0 +1,308 @@ +import { useEffect, useState, type FormEvent } from "react"; + +import { FormField, SwitchButton } from "../../shared/form-controls"; +import { Button } from "../../shared/ui-controls"; +import { shortDateTime } from "../../shared/utils"; +import { + checkInstanceEmail, + instanceError, + loadInstance, + patchInstance, + type EmailPayload, + type InstancePayload, +} from "./instance"; + +// «Платформа» (Настройки): свойства инсталляции, а не организации. Адрес +// запомнил мастер первого запуска — по нему человек и зашёл, поднимая докер на +// сервере; здесь его меняют, когда завели домен и поставили TLS. Из него +// строятся внешние ссылки: вложения знаний и файлы статей уходят клиенту в +// мессенджер, и «localhost» там мёртв. Почта — соседней карточкой: без неё +// нельзя ни пригласить сотрудника, ни сбросить пароль, а задавать её в +// переменных окружения больше негде. Relay для звонков живёт в «Голосовых и +// звонках» — там же, где включают сами звонки. + +type EmailDraft = { + host: string; + port: string; + user: string; + password: string; + useTls: boolean; + from: string; +}; + +function emailDraftOf(email: EmailPayload): EmailDraft { + return { + host: email.host, + port: String(email.port || 587), + user: email.user, + password: "", + useTls: email.useTls, + from: email.from, + }; +} + +function savedLabel(updatedAt: string | null): string { + if (!updatedAt) return "Ещё не сохранялось"; + return `Сохранено ${shortDateTime(updatedAt)}`; +} + +export function PlatformSettingsCard({ canManage }: { canManage: boolean }) { + const [current, setCurrent] = useState(null); + const [loadError, setLoadError] = useState(""); + + useEffect(() => { + loadInstance() + .then(setCurrent) + .catch(() => setLoadError("Не удалось загрузить настройки установки")); + }, []); + + if (loadError) return
{loadError}
; + if (!current) return null; + + return ( + <> + + + + ); +} + +function AddressCard({ canManage, current, onSaved }: { + canManage: boolean; + current: InstancePayload; + onSaved: (payload: InstancePayload) => void; +}) { + const [host, setHost] = useState(current.publicHost); + const [scheme, setScheme] = useState<"http" | "https">(current.publicScheme); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + const [errorText, setErrorText] = useState(""); + const [fieldErrors, setFieldErrors] = useState>({}); + + const touch = () => { setFieldErrors({}); setMessage(""); setErrorText(""); }; + + async function save() { + setBusy(true); + setMessage(""); + setErrorText(""); + setFieldErrors({}); + try { + const payload = await patchInstance({ publicHost: host, publicScheme: scheme }); + onSaved(payload); + setHost(payload.publicHost); + setScheme(payload.publicScheme); + setMessage("Сохранено. Ссылки на файлы пойдут по этому адресу."); + } catch (error) { + const { detail, errors } = instanceError(error); + setErrorText(detail); + setFieldErrors(errors); + } finally { + setBusy(false); + } + } + + function submit(event: FormEvent) { + event.preventDefault(); + void save(); + } + + return ( +
+
+
+ Адрес установки + По нему открывают систему и по нему строятся ссылки на файлы +
+
+ {(["http", "https"] as const).map((value) => ( + + ))} +
+
+

+ Пока домена нет, здесь стоит адрес сервера — тот, на котором вы прошли первый запуск. + Заведёте домен и поставите перед установкой TLS — впишите его и переключите на https. + Ссылки будут вида {scheme}://{host || "адрес"}/… +

+
+ { setHost(value); touch(); }} + /> +
+ {errorText &&
{errorText}
} + {canManage && ( +
+ {message || savedLabel(current.updatedAt)} + +
+ )} +
+ ); +} + +function EmailCard({ canManage, current, onSaved }: { + canManage: boolean; + current: InstancePayload; + onSaved: (payload: InstancePayload) => void; +}) { + const [draft, setDraft] = useState(() => emailDraftOf(current.email)); + const [busy, setBusy] = useState<"" | "save" | "check">(""); + const [message, setMessage] = useState(""); + const [errorText, setErrorText] = useState(""); + const [fieldErrors, setFieldErrors] = useState>({}); + + const touch = () => { setFieldErrors({}); setMessage(""); setErrorText(""); }; + const set = (key: keyof EmailDraft) => (value: string) => { setDraft({ ...draft, [key]: value }); touch(); }; + + async function save() { + setBusy("save"); + setMessage(""); + setErrorText(""); + setFieldErrors({}); + try { + const payload = await patchInstance({ + email: { + host: draft.host, + port: Number(draft.port) || 587, + user: draft.user, + password: draft.password, + useTls: draft.useTls, + from: draft.from, + }, + }); + onSaved(payload); + setDraft(emailDraftOf(payload.email)); + setMessage("Сохранено. Письма пойдут через этот сервер."); + } catch (error) { + const { detail, errors } = instanceError(error); + setErrorText(detail); + setFieldErrors(errors); + } finally { + setBusy(""); + } + } + + async function check() { + setBusy("check"); + setMessage(""); + setErrorText(""); + try { + const sent = await checkInstanceEmail(); + setMessage(`Письмо отправлено на ${sent}. Не пришло — проверьте папку «Спам».`); + } catch (error) { + setErrorText(instanceError(error).detail); + } finally { + setBusy(""); + } + } + + function submit(event: FormEvent) { + event.preventDefault(); + void save(); + } + + return ( +
+
+
+ Исходящая почта + Приглашения сотрудникам и сброс пароля +
+ {/* Шифрование канала до SMTP — двоичный выбор, значит переключатель + (тот же стандарт, что у точек входа в «Голосовых и звонках»). */} +
+ TLS + { setDraft({ ...draft, useTls: !draft.useTls }); touch(); }} + /> +
+
+ {!current.email.configured && ( +

+ Почта не настроена: письма никуда не уходят, пригласить сотрудника не получится. +

+ )} +
+ + + + + +
+ {errorText &&
{errorText}
} + {message &&
{message}
} + {canManage && ( +
+ {savedLabel(current.updatedAt)} + {current.email.configured && ( + + )} + +
+ )} +
+ ); +} diff --git a/apps/internal-ui/src/features/settings/SettingsPage.tsx b/apps/internal-ui/src/features/settings/SettingsPage.tsx index aba0460..886a414 100644 --- a/apps/internal-ui/src/features/settings/SettingsPage.tsx +++ b/apps/internal-ui/src/features/settings/SettingsPage.tsx @@ -14,7 +14,7 @@ import { Icon } from "../../shared/icons"; import { useMediaQuery } from "../../shared/useMediaQuery"; import { DemoDataCard } from "./DemoDataCard"; import { CommunicationSettingsCard } from "./CommunicationSettingsCard"; -import { InstanceAddressCard } from "./InstanceAddressCard"; +import { PlatformSettingsCard } from "./PlatformSettingsCard"; import { StorageSettingsCard } from "./StorageSettingsCard"; import { GroupsSettingsCard } from "./GroupsSettingsCard"; import { DEFAULT_SETTINGS_SECTION, SETTINGS_SECTIONS, type SettingsSectionKey } from "./sections"; @@ -24,6 +24,8 @@ import { useIntegrations } from "./useIntegrations"; // раздел на экране; ниже субменю — переходы на отдельные экраны «База знаний» и // «Аудит действий». Профиль пользователя сюда не входит — это отдельная // страница из меню пользователя. OWNER и ADMIN видят одно и то же (§3). +// «Платформа» кадра в базлайне не имеет — раздел собран по стандарту N6 +// («Хранилище файлов»): заголовок карточки, поля, ряд действий. export function SettingsPage({ user, onUserUpdated, reload, groups = [], section, openSection, setRoute }: { user: SessionUser; @@ -151,6 +153,7 @@ function SectionBody({ section, user, onUserUpdated, reload, groups, integration if (section === "organization") return ; if (section === "groups") return ; if (section === "communication") return ; + if (section === "platform") return ; if (section === "storage") return ; if (section === "demo") return ; if (integrations.loading) return ; @@ -169,23 +172,20 @@ function OrganizationSection({ user, onUserUpdated }: { user: SessionUser; onUse const page = useAdministration({ section: "organization", user, onUserUpdated }); if (page.loading) return ; if (!page.organization) return ; + // Раздел равен кадру N1: одна карточка. Адрес установки, почта и relay — + // свойства инсталляции и живут в «Платформе» и «Голосовых и звонках». return ( - <> - void page.save()} - onUploadLogo={(file) => void page.uploadLogo(file)} - onRemoveLogo={() => void page.removeLogo()} - /> - {/* Адрес установки — свойство инсталляции, а не организации, но живёт - рядом: в облаке он один на всех, в коробке организация одна. */} - - + void page.save()} + onUploadLogo={(file) => void page.uploadLogo(file)} + onRemoveLogo={() => void page.removeLogo()} + /> ); } diff --git a/apps/internal-ui/src/features/settings/StorageSettingsCard.tsx b/apps/internal-ui/src/features/settings/StorageSettingsCard.tsx index cf15732..6b8f8c1 100644 --- a/apps/internal-ui/src/features/settings/StorageSettingsCard.tsx +++ b/apps/internal-ui/src/features/settings/StorageSettingsCard.tsx @@ -132,7 +132,7 @@ export function StorageSettingsCard({ canManage }: { canManage: boolean }) { return (
-
+
Где хранить файлы Вложения знаний, голосовые, фото и логотипы diff --git a/apps/internal-ui/src/features/settings/instance.ts b/apps/internal-ui/src/features/settings/instance.ts new file mode 100644 index 0000000..c88462e --- /dev/null +++ b/apps/internal-ui/src/features/settings/instance.ts @@ -0,0 +1,53 @@ +import { api } from "../../api/client"; + +// Настройки инсталляции (раздел «Платформа» и карточка relay в «Голосовых и +// звонках»). Один эндпоинт на всю установку: адрес, исходящая почта и TURN. +// PATCH частичный — блоки email и turn применяются, только если пришли в теле, +// поэтому каждая карточка сохраняет свою часть и не затирает соседние. + +export type EmailPayload = { + host: string; + port: number; + user: string; + hasPassword: boolean; + useTls: boolean; + from: string; + configured: boolean; +}; + +export type InstancePayload = { + publicHost: string; + publicScheme: "http" | "https"; + publicUrl: string; + updatedAt: string | null; + email: EmailPayload; + turn: { urls: string[]; ttlSeconds: number; secretReady: boolean }; +}; + +export const INSTANCE_BASE = "/api/v1/company/administration/instance/"; + +export function loadInstance(): Promise { + return api<{ instance: InstancePayload }>(INSTANCE_BASE).then((payload) => payload.instance); +} + +export function patchInstance(body: unknown): Promise { + return api<{ instance: InstancePayload }>(INSTANCE_BASE, { + method: "PATCH", + body: JSON.stringify(body), + }).then((payload) => payload.instance); +} + +// Проверка почты: отправить письмо себе и увидеть ошибку SMTP сразу, а не +// тогда, когда сотрудник не получил приглашение. +export function checkInstanceEmail(): Promise { + return api<{ sent: string }>(`${INSTANCE_BASE}email-check/`, { method: "POST", body: "{}" }) + .then((payload) => payload.sent); +} + +export function instanceError(error: unknown): { detail: string; errors: Record } { + if (error && typeof error === "object" && "payload" in error) { + const payload = (error as { payload?: { detail?: string; errors?: Record } }).payload; + return { detail: payload?.detail ?? "Не удалось сохранить", errors: payload?.errors ?? {} }; + } + return { detail: error instanceof Error ? error.message : "Не удалось сохранить", errors: {} }; +} diff --git a/apps/internal-ui/src/features/settings/sections.ts b/apps/internal-ui/src/features/settings/sections.ts index efbf9a7..e5ad595 100644 --- a/apps/internal-ui/src/features/settings/sections.ts +++ b/apps/internal-ui/src/features/settings/sections.ts @@ -11,6 +11,7 @@ export type SettingsSectionKey = | "integrations" | "communication" | "storage" + | "platform" | "demo"; export type SettingsSection = { @@ -30,6 +31,8 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [ icon: "building", heading: "Организация", lead: "Название, логотип и региональные параметры. Видны сотрудникам, в виджете и письмах.", + // Адрес установки и почта сюда не входят — это свойства инсталляции, а не + // организации, и живут в разделе «Платформа». Раздел равен кадру N1. }, { key: "groups", @@ -69,6 +72,13 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [ heading: "Хранилище файлов", lead: "Вложения, голосовые, фото и логотипы — на диске установки или во внешнем S3-совместимом хранилище.", }, + { + key: "platform", + label: "Платформа", + icon: "globe", + heading: "Платформа", + lead: "Свойства самой установки: адрес, по которому её открывают и по которому строятся ссылки на файлы, и почтовый сервер для приглашений и сброса пароля.", + }, { key: "demo", label: "Демо-данные", diff --git a/apps/internal-ui/src/features/settings/styles.css b/apps/internal-ui/src/features/settings/styles.css index 2b641c8..1acfcdc 100644 --- a/apps/internal-ui/src/features/settings/styles.css +++ b/apps/internal-ui/src/features/settings/styles.css @@ -154,6 +154,15 @@ padding: 28px 40px 48px; } +/* Раздел может состоять из нескольких карточек («Платформа» — адрес и почта, + «Голосовые и звонки» — матрица и relay). Без этого они шли встык и рамки + складывались в двойную линию. */ +.settings-content-inner > .administration-card ~ .administration-card, +.settings-content-inner > .table-card ~ .administration-card, +.settings-content-inner > .settings-section-note ~ .administration-card { + margin-top: 18px; +} + .settings-content-inner.is-wide { max-width: 1000px; } @@ -571,9 +580,11 @@ font-size: 12px; } -/* --- N6 Хранилище файлов --- */ +/* Заголовок карточки раздела: имя · пояснение слева, управляющий элемент + справа, снизу разделитель (кадр N6). Один стандарт на все разделы — + «Хранилище файлов», «Платформа», relay в «Голосовых и звонках». */ -.settings-storage-head { +.settings-card-head { display: flex; align-items: center; justify-content: space-between; @@ -582,20 +593,32 @@ border-bottom: 1px solid var(--n-9); } -.settings-storage-head strong { +.settings-card-head strong { display: block; color: var(--n-1); font-size: 13.5px; font-weight: 700; } -.settings-storage-head small { +.settings-card-head small { display: block; margin-top: 3px; color: var(--n-4); font-size: 12px; } +.settings-card-head-switch { + display: flex; + flex: none; + align-items: center; + gap: 8px; + color: var(--n-2); + font-size: 12.5px; + font-weight: 600; +} + +/* --- N6 Хранилище файлов --- */ + .storage-card .settings-section-note { margin: 16px 0 18px; color: var(--n-3); @@ -867,6 +890,7 @@ font-size: 15px; } + /* Липкая панель действий рассчитана на раздел из одной формы (кадры N1/N6). */ .settings-content .administration-actions { position: sticky; bottom: 0; @@ -876,6 +900,18 @@ border-top: 1px solid var(--n-8); } + /* В разделе из нескольких карточек она врёт: висит под всеми, а сохраняет + свою. Плюс отрицательный margin вылезал за карточку и накрывал соседнюю. + Там кнопка остаётся в потоке. */ + .settings-content .administration-card:has(~ .administration-card) .administration-actions, + .settings-content .administration-card ~ .administration-card .administration-actions { + position: static; + margin: 18px 0 0; + padding: 14px 0 0; + background: none; + border-top: 1px solid var(--n-9); + } + .settings-content .administration-actions .primary-button { flex: 1; height: 46px; diff --git a/apps/internal-ui/src/shared/form-controls.tsx b/apps/internal-ui/src/shared/form-controls.tsx index 24aad80..376cdf9 100644 --- a/apps/internal-ui/src/shared/form-controls.tsx +++ b/apps/internal-ui/src/shared/form-controls.tsx @@ -56,11 +56,12 @@ export function SelectField({ disabled = false, label, value, onChange, options ); } -export function TextAreaField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { +export function TextAreaField({ disabled = false, label, value, onChange }: { disabled?: boolean; label: string; value: string; onChange: (value: string) => void }) { + const className = ["readonly-field", "form-field", "wide", disabled ? "is-readonly is-disabled" : "is-editable"].join(" "); return ( -