mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
💄 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:
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user