💄 refactor(settings): раздел «Платформа» — настройки инсталляции вон из «Организации»

Адрес установки, 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 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-08 01:18:39 +03:00
1 parent 146052916b
commit eaee6a9f7b
10 files changed
+547 -337

No files matched your search

@@ -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<InstancePayload | null>(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<Record<string, string>>({});
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 (
<form className="administration-card" onSubmit={submit}>
<div className="settings-card-head">
<div>
<strong>TURN для звонков</strong>
<small>Relay на случай, когда прямое соединение не проходит</small>
</div>
</div>
<p className="settings-section-note">
Нужен, только если звонки идут через сети со строгим NAT. Общий секрет с сервером
relay {current.turn.secretReady
? "уже создан установкой — вводить его не нужно."
: "будет создан при следующем запуске стека."}
</p>
<div className="administration-fields">
<TextAreaField
disabled={!canManage}
label="Адреса TURN — по одному в строке"
value={urls}
onChange={(value) => { setUrls(value); touch(); }}
/>
<FormField
disabled={!canManage}
error={fieldErrors.turnTtlSeconds}
label="Время жизни доступа, секунд"
mono
value={ttl}
onChange={(value) => { setTtl(value); touch(); }}
/>
</div>
{errorText && <div className="administration-message error" role="alert">{errorText}</div>}
{message && <div className="administration-message">{message}</div>}
{canManage && (
<div className="administration-actions">
<small className="administration-saved">
{current.updatedAt ? `Сохранено ${shortDateTime(current.updatedAt)}` : "Ещё не сохранялось"}
</small>
<Button type="submit" variant="primary" disabled={busy}>
{busy ? "Сохранение" : "Сохранить"}
</Button>
</div>
)}
</form>
);
}
@@ -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 && (
<p className="settings-section-note">Голосовые, присланные клиентом из мессенджера, принимаются всегда. Почта звонки не поддерживает. Изменения применяются сразу.</p>
)}
<CallsRelayCard canManage={canManage} />
</>
);
}
@@ -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<string, string> } {
if (error && typeof error === "object" && "payload" in error) {
const payload = (error as { payload?: { detail?: string; errors?: Record<string, string> } }).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<InstancePayload | null>(null);
const [host, setHost] = useState("");
const [scheme, setScheme] = useState<"http" | "https">("http");
const [email, setEmail] = useState<EmailDraft | null>(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<Record<string, string>>({});
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 (
<form className="administration-card" onSubmit={submit}>
<div className="settings-storage-head">
<div>
<strong>Адрес установки</strong>
<small>По нему открывают систему и по нему строятся ссылки на файлы</small>
</div>
<div className="appearance-theme-options">
{(["http", "https"] as const).map((value) => (
<button
className={scheme === value ? "active" : ""}
disabled={!canManage || Boolean(busy)}
key={value}
type="button"
onClick={() => { setScheme(value); touch(); }}
>
{value}
</button>
))}
</div>
</div>
<p className="settings-section-note">
Пока домена нет, здесь стоит адрес сервера — тот, на котором вы прошли первый запуск.
Заведёте домен и поставите перед установкой TLS — впишите его и переключите на https.
</p>
<div className="administration-fields">
<FormField
disabled={!canManage}
error={fieldErrors.publicHost}
label="Домен или IP"
mono
placeholder="crm.example.com"
value={host}
wide
onChange={(value) => { setHost(value); touch(); }}
/>
</div>
<p className="settings-section-note">
Ссылки будут вида <b>{scheme}://{host || "адрес"}/…</b>
</p>
<div className="settings-storage-head">
<div>
<strong>Исходящая почта</strong>
<small>Приглашения сотрудникам и сброс пароля</small>
</div>
<div className="appearance-theme-options">
{([[true, "TLS"], [false, "без TLS"]] as Array<[boolean, string]>).map(([value, label]) => (
<button
className={email.useTls === value ? "active" : ""}
disabled={!canManage || Boolean(busy)}
key={label}
type="button"
onClick={() => { setEmail({ ...email, useTls: value }); touch(); }}
>
{label}
</button>
))}
</div>
</div>
{!current.email.configured && (
<p className="settings-section-note">
Почта не настроена: письма никуда не уходят, пригласить сотрудника не получится.
</p>
)}
<div className="administration-fields">
<FormField
disabled={!canManage}
error={fieldErrors.emailHost}
label="SMTP-сервер"
mono
placeholder="smtp.example.com"
value={email.host}
onChange={setEmailField("host")}
/>
<FormField
disabled={!canManage}
error={fieldErrors.emailPort}
label="Порт"
mono
placeholder="587"
value={email.port}
onChange={setEmailField("port")}
/>
<FormField
disabled={!canManage}
label="Пользователь"
mono
placeholder="robot@example.com"
value={email.user}
onChange={setEmailField("user")}
/>
<FormField
disabled={!canManage}
label="Пароль"
mono
placeholder={current.email.hasPassword ? "•••••••• (сохранён)" : ""}
type="password"
value={email.password}
onChange={setEmailField("password")}
/>
<FormField
disabled={!canManage}
label="Отправитель"
placeholder="Chatballs <no-reply@example.com>"
value={email.from}
wide
onChange={setEmailField("from")}
/>
</div>
<div className="settings-storage-head">
<div>
<strong>TURN для звонков</strong>
<small>Relay на случай, когда прямое соединение не проходит</small>
</div>
</div>
<p className="settings-section-note">
Нужен, только если звонки идут через сети со строгим NAT. Общий секрет с сервером
relay {current.turn.secretReady ? "уже создан установкой — вводить его не нужно." : "будет создан при следующем запуске стека."}
</p>
<div className="administration-fields">
<TextAreaField
label="Адреса TURN — по одному в строке"
value={turnUrls}
onChange={(value) => { setTurnUrls(value); touch(); }}
/>
<FormField
disabled={!canManage}
error={fieldErrors.turnTtlSeconds}
label="Время жизни доступа, секунд"
mono
value={turnTtl}
onChange={(value) => { setTurnTtl(value); touch(); }}
/>
</div>
{errorText && <div className="administration-message error" role="alert">{errorText}</div>}
{message && <div className="administration-message">{message}</div>}
{canManage && (
<div className="administration-actions">
<Button variant="primary" disabled={Boolean(busy)} type="submit">
{busy === "save" ? "Сохраняем…" : "Сохранить"}
</Button>
{current.email.configured && (
<Button variant="secondary" disabled={Boolean(busy)} onClick={() => void check()}>
{busy === "check" ? "Отправляем…" : "Отправить тестовое письмо"}
</Button>
)}
{current.updatedAt && (
<span className="settings-section-note">сохранено {shortDateTime(current.updatedAt)}</span>
)}
</div>
)}
</form>
);
}
@@ -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<InstancePayload | null>(null);
const [loadError, setLoadError] = useState("");
useEffect(() => {
loadInstance()
.then(setCurrent)
.catch(() => setLoadError("Не удалось загрузить настройки установки"));
}, []);
if (loadError) return <div className="settings-section-error">{loadError}</div>;
if (!current) return null;
return (
<>
<AddressCard canManage={canManage} current={current} onSaved={setCurrent} />
<EmailCard canManage={canManage} current={current} onSaved={setCurrent} />
</>
);
}
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<Record<string, string>>({});
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 (
<form className="administration-card" onSubmit={submit}>
<div className="settings-card-head">
<div>
<strong>Адрес установки</strong>
<small>По нему открывают систему и по нему строятся ссылки на файлы</small>
</div>
<div className="appearance-theme-options">
{(["http", "https"] as const).map((value) => (
<button
className={scheme === value ? "active" : ""}
disabled={!canManage || busy}
key={value}
type="button"
onClick={() => { setScheme(value); touch(); }}
>
{value}
</button>
))}
</div>
</div>
<p className="settings-section-note">
Пока домена нет, здесь стоит адрес сервера — тот, на котором вы прошли первый запуск.
Заведёте домен и поставите перед установкой TLS — впишите его и переключите на https.
Ссылки будут вида <b>{scheme}://{host || "адрес"}/…</b>
</p>
<div className="administration-fields">
<FormField
disabled={!canManage}
error={fieldErrors.publicHost}
label="Домен или IP"
mono
placeholder="crm.example.com"
value={host}
wide
onChange={(value) => { setHost(value); touch(); }}
/>
</div>
{errorText && <div className="administration-message error" role="alert">{errorText}</div>}
{canManage && (
<div className="administration-actions">
<small className="administration-saved">{message || savedLabel(current.updatedAt)}</small>
<Button type="submit" variant="primary" disabled={busy || !host.trim()}>
{busy ? "Сохранение" : "Сохранить"}
</Button>
</div>
)}
</form>
);
}
function EmailCard({ canManage, current, onSaved }: {
canManage: boolean;
current: InstancePayload;
onSaved: (payload: InstancePayload) => void;
}) {
const [draft, setDraft] = useState<EmailDraft>(() => emailDraftOf(current.email));
const [busy, setBusy] = useState<"" | "save" | "check">("");
const [message, setMessage] = useState("");
const [errorText, setErrorText] = useState("");
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
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 (
<form className="administration-card" onSubmit={submit}>
<div className="settings-card-head">
<div>
<strong>Исходящая почта</strong>
<small>Приглашения сотрудникам и сброс пароля</small>
</div>
{/* Шифрование канала до SMTP — двоичный выбор, значит переключатель
(тот же стандарт, что у точек входа в «Голосовых и звонках»). */}
<div className="settings-card-head-switch">
<span>TLS</span>
<SwitchButton
checked={draft.useTls}
className="ui-switch is-compact"
disabled={!canManage || Boolean(busy)}
label="Шифрование TLS до SMTP-сервера"
onClick={() => { setDraft({ ...draft, useTls: !draft.useTls }); touch(); }}
/>
</div>
</div>
{!current.email.configured && (
<p className="settings-section-note">
Почта не настроена: письма никуда не уходят, пригласить сотрудника не получится.
</p>
)}
<div className="administration-fields">
<FormField
disabled={!canManage}
error={fieldErrors.emailHost}
label="SMTP-сервер"
mono
placeholder="smtp.example.com"
value={draft.host}
onChange={set("host")}
/>
<FormField
disabled={!canManage}
error={fieldErrors.emailPort}
label="Порт"
mono
placeholder="587"
value={draft.port}
onChange={set("port")}
/>
<FormField
disabled={!canManage}
label="Пользователь"
mono
placeholder="robot@example.com"
value={draft.user}
onChange={set("user")}
/>
<FormField
disabled={!canManage}
label="Пароль"
mono
placeholder={current.email.hasPassword ? "•••••••• (сохранён)" : ""}
type="password"
value={draft.password}
onChange={set("password")}
/>
<FormField
disabled={!canManage}
label="Отправитель"
placeholder="Chatballs <no-reply@example.com>"
value={draft.from}
wide
onChange={set("from")}
/>
</div>
{errorText && <div className="administration-message error" role="alert">{errorText}</div>}
{message && <div className="administration-message">{message}</div>}
{canManage && (
<div className="administration-actions">
<small className="administration-saved">{savedLabel(current.updatedAt)}</small>
{current.email.configured && (
<Button variant="secondary" disabled={Boolean(busy)} onClick={() => void check()}>
{busy === "check" ? "Отправляем…" : "Отправить тестовое письмо"}
</Button>
)}
<Button type="submit" variant="primary" disabled={Boolean(busy)}>
{busy === "save" ? "Сохранение" : "Сохранить"}
</Button>
</div>
)}
</form>
);
}
@@ -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 <OrganizationSection user={user} onUserUpdated={onUserUpdated} />;
if (section === "groups") return <GroupsSettingsCard groups={groups} reload={reload} />;
if (section === "communication") return <CommunicationSettingsCard canManage={canManageSettings(user)} />;
if (section === "platform") return <PlatformSettingsCard canManage={canManageSettings(user)} />;
if (section === "storage") return <StorageSettingsCard canManage={canManageSettings(user)} />;
if (section === "demo") return <DemoDataCard reload={reload} />;
if (integrations.loading) return <LoadingState />;
@@ -169,23 +172,20 @@ function OrganizationSection({ user, onUserUpdated }: { user: SessionUser; onUse
const page = useAdministration({ section: "organization", user, onUserUpdated });
if (page.loading) return <LoadingState />;
if (!page.organization) return <EmptyState title={page.error || "Не удалось загрузить настройки"} />;
// Раздел равен кадру N1: одна карточка. Адрес установки, почта и relay —
// свойства инсталляции и живут в «Платформе» и «Голосовых и звонках».
return (
<>
<OrganizationSettingsForm
organization={page.organization}
canManage={canManageSettings(user)}
saving={page.saving}
message={page.message}
error={page.error}
timezones={page.timezones}
onChange={page.setOrganization}
onSave={() => void page.save()}
onUploadLogo={(file) => void page.uploadLogo(file)}
onRemoveLogo={() => void page.removeLogo()}
/>
{/* Адрес установки — свойство инсталляции, а не организации, но живёт
рядом: в облаке он один на всех, в коробке организация одна. */}
<InstanceAddressCard canManage={canManageSettings(user)} />
</>
<OrganizationSettingsForm
organization={page.organization}
canManage={canManageSettings(user)}
saving={page.saving}
message={page.message}
error={page.error}
timezones={page.timezones}
onChange={page.setOrganization}
onSave={() => void page.save()}
onUploadLogo={(file) => void page.uploadLogo(file)}
onRemoveLogo={() => void page.removeLogo()}
/>
);
}
@@ -132,7 +132,7 @@ export function StorageSettingsCard({ canManage }: { canManage: boolean }) {
return (
<form className="administration-card storage-card" onSubmit={submit}>
<div className="settings-storage-head">
<div className="settings-card-head">
<div>
<strong>Где хранить файлы</strong>
<small>Вложения знаний, голосовые, фото и логотипы</small>
@@ -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<InstancePayload> {
return api<{ instance: InstancePayload }>(INSTANCE_BASE).then((payload) => payload.instance);
}
export function patchInstance(body: unknown): Promise<InstancePayload> {
return api<{ instance: InstancePayload }>(INSTANCE_BASE, {
method: "PATCH",
body: JSON.stringify(body),
}).then((payload) => payload.instance);
}
// Проверка почты: отправить письмо себе и увидеть ошибку SMTP сразу, а не
// тогда, когда сотрудник не получил приглашение.
export function checkInstanceEmail(): Promise<string> {
return api<{ sent: string }>(`${INSTANCE_BASE}email-check/`, { method: "POST", body: "{}" })
.then((payload) => payload.sent);
}
export function instanceError(error: unknown): { detail: string; errors: Record<string, string> } {
if (error && typeof error === "object" && "payload" in error) {
const payload = (error as { payload?: { detail?: string; errors?: Record<string, string> } }).payload;
return { detail: payload?.detail ?? "Не удалось сохранить", errors: payload?.errors ?? {} };
}
return { detail: error instanceof Error ? error.message : "Не удалось сохранить", errors: {} };
}
@@ -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: "Демо-данные",
@@ -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;
@@ -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 (
<label className="readonly-field form-field is-editable wide">
<label className={className}>
<span>{label}</span>
<textarea value={value} onChange={(event) => onChange(event.target.value)} />
<textarea value={value} disabled={disabled} readOnly={disabled} onChange={(event) => onChange(event.target.value)} />
</label>
);
}