Настройка «Эталон mail-домена» теперь хранит список доменов вместо
одного — email при регистрации принимается, если совпадает с любым из
них. Старое значение в БД ({"domain": str|None}) читается прозрачно
(обратная совместимость без Alembic-миграции) и переписывается в новую
форму ({"domains": [...]}) при первом же сохранении настроек. В админке
добавление/удаление доменов — списком чипов; на экране регистрации
подсказка о несовпадении домена перечисляет все эталонные варианты.
446 lines
20 KiB
TypeScript
446 lines
20 KiB
TypeScript
import { useState } from 'react'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { AlertTriangle, X } from 'lucide-react'
|
||
import {
|
||
getAdminSettings,
|
||
sendTestEmail,
|
||
updateAdminSettings,
|
||
type AiLevel,
|
||
type SettingsOut,
|
||
type SettingsUpdateIn,
|
||
type TestEmailOut,
|
||
} from '@/api/admin'
|
||
import type { SummaryRecipientsMode } from '@/api/conferences'
|
||
import { ApiError, errorDetail } from '@/api/client'
|
||
import { useAuth } from '@/auth/useAuth'
|
||
import { Select } from '@/components/ui/Select'
|
||
import { useToast } from '@/components/ui/ToastProvider'
|
||
|
||
const SUMMARY_RECIPIENTS_OPTIONS = [
|
||
{ value: 'all', label: 'Всем участникам' },
|
||
{ value: 'owner', label: 'Только организатору' },
|
||
]
|
||
|
||
const AI_LEVEL_LABEL: Record<AiLevel, string> = {
|
||
min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)',
|
||
medium: 'Средний',
|
||
max: 'Максимальный',
|
||
}
|
||
|
||
/**
|
||
* Вкладка «Настройки» админки — раздела нет в макете `admin.html`, построена
|
||
* по паттерну того же макета (`section-tab`/`table-card` → карточки-секции
|
||
* с `toggle-row`/radio).
|
||
* Тумблер «Транскрибация и суммаризация» — единый переключатель AI-модуля
|
||
* (см. `SettingsOut.transcription_enabled`).
|
||
*
|
||
* Форма (`AdminSettingsForm`) вынесена отдельно и монтируется только после
|
||
* загрузки `data` — локальное состояние инициализируется прямо из пропсов
|
||
* при монтировании, без `useEffect`-синхронизации (react-hooks/set-state-in-effect).
|
||
*/
|
||
export function AdminSettingsTab() {
|
||
const { data, isLoading } = useQuery({ queryKey: ['admin', 'settings'], queryFn: getAdminSettings })
|
||
|
||
if (isLoading || !data) {
|
||
return <p className="field-hint">Загрузка настроек…</p>
|
||
}
|
||
|
||
return <AdminSettingsForm data={data} />
|
||
}
|
||
|
||
function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||
const queryClient = useQueryClient()
|
||
const toast = useToast()
|
||
const { user } = useAuth()
|
||
|
||
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
|
||
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
|
||
const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level)
|
||
const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients)
|
||
const [timezone, setTimezone] = useState(data.display_timezone)
|
||
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
|
||
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
|
||
const [emailDomains, setEmailDomains] = useState(data.registration_email_domains)
|
||
const [newDomainInput, setNewDomainInput] = useState('')
|
||
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
|
||
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
|
||
const [testEmailTo, setTestEmailTo] = useState('')
|
||
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
|
||
|
||
const mutation = useMutation({
|
||
mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload),
|
||
onSuccess: (result) => {
|
||
queryClient.setQueryData(['admin', 'settings'], result)
|
||
toast.show('Настройки сохранены', 'success')
|
||
},
|
||
onError: (err: unknown) => {
|
||
if (err instanceof ApiError && err.status === 400) {
|
||
toast.show(errorDetail(err) ?? 'Недоступное значение — проверьте уровень AI, таймзону и домен почты', 'error')
|
||
} else {
|
||
toast.show('Не удалось сохранить настройки', 'error')
|
||
}
|
||
},
|
||
})
|
||
|
||
const testEmailMutation = useMutation({
|
||
mutationFn: () => sendTestEmail(testEmailTo.trim() ? { to: testEmailTo.trim() } : {}),
|
||
onSuccess: (result) => setTestEmailResult(result),
|
||
onError: (err: unknown) => {
|
||
setTestEmailResult({
|
||
success: false,
|
||
message: (err instanceof ApiError && errorDetail(err)) || 'Не удалось отправить тестовое письмо',
|
||
smtp_host: null,
|
||
smtp_port: null,
|
||
})
|
||
},
|
||
})
|
||
|
||
function addDomain() {
|
||
const domain = newDomainInput.trim().toLowerCase().replace(/^@/, '')
|
||
if (!domain || emailDomains.includes(domain)) {
|
||
setNewDomainInput('')
|
||
return
|
||
}
|
||
setEmailDomains([...emailDomains, domain])
|
||
setNewDomainInput('')
|
||
}
|
||
|
||
function removeDomain(domain: string) {
|
||
setEmailDomains(emailDomains.filter((d) => d !== domain))
|
||
}
|
||
|
||
function handleSave() {
|
||
// Отправляем только реально изменённые поля (`SettingsUpdateIn` — набор
|
||
// опциональных полей именно для этого): иначе, например, ai_level
|
||
// уходил бы в PUT нетронутым при каждом сохранении и на слабом железе
|
||
// валился бы в 400, блокируя правку вообще любой другой настройки.
|
||
const payload: SettingsUpdateIn = {}
|
||
if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled
|
||
if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled
|
||
if (aiLevel !== data.ai_level) payload.ai_level = aiLevel
|
||
if (recipients !== data.summary_recipients) payload.summary_recipients = recipients
|
||
if (timezone.trim() !== data.display_timezone) payload.display_timezone = timezone.trim()
|
||
if (teamChoiceEnabled !== data.registration_team_choice) {
|
||
payload.registration_team_choice = teamChoiceEnabled
|
||
}
|
||
if (domainVerificationEnabled !== data.registration_email_domain_enabled) {
|
||
payload.registration_email_domain_enabled = domainVerificationEnabled
|
||
}
|
||
if (JSON.stringify(emailDomains) !== JSON.stringify(data.registration_email_domains)) {
|
||
payload.registration_email_domains = emailDomains
|
||
}
|
||
if (contactEmailEnabled !== data.contact_email_enabled) {
|
||
payload.contact_email_enabled = contactEmailEnabled
|
||
}
|
||
const trimmedContactEmail = contactEmail.trim() || null
|
||
if (trimmedContactEmail !== (data.contact_email ?? null)) {
|
||
payload.contact_email = trimmedContactEmail
|
||
}
|
||
mutation.mutate(payload)
|
||
}
|
||
|
||
return (
|
||
<div className="settings-grid">
|
||
<section className="settings-card">
|
||
<h2>Модули</h2>
|
||
<p className="desc">Включение и отключение возможностей инстанса — переключатели плагинов из поставки.</p>
|
||
|
||
<div className="settings-card-body settings-card-body--spread">
|
||
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
|
||
<div className="toggle-copy">
|
||
<strong>Чат конференции</strong>
|
||
<span>Текстовый чат внутри комнаты для участников</span>
|
||
</div>
|
||
<label className="switch">
|
||
<input type="checkbox" checked={chatEnabled} onChange={(e) => setChatEnabled(e.target.checked)} />
|
||
<span className="slider" />
|
||
</label>
|
||
</div>
|
||
|
||
<div className="toggle-row">
|
||
<div className="toggle-copy">
|
||
<strong>Транскрибация и суммаризация (AI)</strong>
|
||
<span>Батч-обработка записи после конференции: расшифровка речи и саммари</span>
|
||
{aiEnabled && !data.transcription_queue_served && (
|
||
<span className="ai-level-reason">
|
||
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" />
|
||
AI включён, но обработка очереди транскрибации недоступна — сеансы не будут обработаны
|
||
</span>
|
||
)}
|
||
</div>
|
||
<label className="switch">
|
||
<input type="checkbox" checked={aiEnabled} onChange={(e) => setAiEnabled(e.target.checked)} />
|
||
<span className="slider" />
|
||
</label>
|
||
</div>
|
||
|
||
<div className="toggle-row">
|
||
<div className="toggle-copy">
|
||
<strong>Разрешить выбор команды при регистрации</strong>
|
||
<span>На экране регистрации появится поле «Команда» со списком команд инстанса</span>
|
||
</div>
|
||
<label className="switch">
|
||
<input
|
||
type="checkbox"
|
||
checked={teamChoiceEnabled}
|
||
onChange={(e) => setTeamChoiceEnabled(e.target.checked)}
|
||
/>
|
||
<span className="slider" />
|
||
</label>
|
||
</div>
|
||
|
||
<div className="toggle-row">
|
||
<div className="toggle-copy">
|
||
<strong>Верификация по домену почты</strong>
|
||
<span>Регистрация только с email эталонного домена (см. плитку «Эталон mail-домена»)</span>
|
||
</div>
|
||
<label className="switch">
|
||
<input
|
||
type="checkbox"
|
||
checked={domainVerificationEnabled}
|
||
onChange={(e) => setDomainVerificationEnabled(e.target.checked)}
|
||
/>
|
||
<span className="slider" />
|
||
</label>
|
||
</div>
|
||
|
||
{/*
|
||
Анонс записи конференций. Чекбокс не имеет состояния и не
|
||
участвует в сохранении формы — функция появится в одной из
|
||
следующих версий, здесь только заглушка-анонс в общем стиле
|
||
«Модулей».
|
||
*/}
|
||
<div className="toggle-row">
|
||
<div className="toggle-copy">
|
||
<strong>Возможность записи конференций</strong>
|
||
<span>Функция появится в ближайших версиях</span>
|
||
</div>
|
||
<label className="switch">
|
||
<input type="checkbox" checked={false} disabled readOnly />
|
||
<span className="slider" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="settings-stack">
|
||
<section className="settings-card">
|
||
<h2>Уровень AI</h2>
|
||
<p className="desc">Качество распознавания и суммаризации — определяет размер моделей и требования к железу.</p>
|
||
|
||
<div className="settings-card-body">
|
||
<div className="ai-level-list">
|
||
{data.ai_levels.map((levelStatus) => (
|
||
<label
|
||
key={levelStatus.level}
|
||
className={`ai-level-option${!levelStatus.available ? ' is-disabled' : ''}`}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="ai-level"
|
||
checked={aiLevel === levelStatus.level}
|
||
disabled={!levelStatus.available || !aiEnabled}
|
||
onChange={() => setAiLevel(levelStatus.level)}
|
||
/>
|
||
<div>
|
||
<strong>{AI_LEVEL_LABEL[levelStatus.level]}</strong>
|
||
{!levelStatus.available && levelStatus.reason && (
|
||
<span className="ai-level-reason">
|
||
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" />
|
||
{levelStatus.reason}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-card">
|
||
<h2>Эталон mail-домена</h2>
|
||
<p className="desc">Домены, с любым из которых сверяется email при регистрации, если включена верификация в «Модулях».</p>
|
||
|
||
<div className="settings-card-body">
|
||
{emailDomains.length > 0 && (
|
||
<div className="participants-chips">
|
||
{emailDomains.map((domain) => (
|
||
<span className="participant-chip" key={domain}>
|
||
{domain}
|
||
<button
|
||
type="button"
|
||
className="participant-chip-remove"
|
||
aria-label={`Убрать домен: ${domain}`}
|
||
onClick={() => removeDomain(domain)}
|
||
disabled={!domainVerificationEnabled}
|
||
>
|
||
<X style={{ width: 12, height: 12 }} aria-hidden="true" />
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="field" style={{ marginBottom: 0 }}>
|
||
<label htmlFor="settings-email-domain">Домен корпоративной почты</label>
|
||
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
|
||
<input
|
||
id="settings-email-domain"
|
||
type="text"
|
||
placeholder="company.ru"
|
||
value={newDomainInput}
|
||
disabled={!domainVerificationEnabled}
|
||
onChange={(e) => setNewDomainInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault()
|
||
addDomain()
|
||
}
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
disabled={!domainVerificationEnabled || !newDomainInput.trim()}
|
||
onClick={addDomain}
|
||
>
|
||
Добавить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<section className="settings-card">
|
||
<h2>Рассылка саммари</h2>
|
||
<p className="desc">Кому по умолчанию отправляется письмо с саммари после конференции (переопределяется в карточке конференции).</p>
|
||
|
||
<div className="settings-card-body settings-card-body--center">
|
||
<div className="field" style={{ marginBottom: 0 }}>
|
||
<label id="settings-recipients-label" htmlFor="settings-recipients">
|
||
Получатели по умолчанию
|
||
</label>
|
||
<Select
|
||
id="settings-recipients"
|
||
aria-labelledby="settings-recipients-label"
|
||
value={recipients}
|
||
onChange={(v) => setRecipients(v as SummaryRecipientsMode)}
|
||
options={SUMMARY_RECIPIENTS_OPTIONS}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-card">
|
||
<h2>Часовой пояс отображения</h2>
|
||
<p className="desc">IANA-таймзона для времени в письмах и .ics-приглашениях (в БД время всегда хранится в UTC).</p>
|
||
|
||
<div className="settings-card-body settings-card-body--center">
|
||
<div className="field" style={{ marginBottom: 0 }}>
|
||
<label htmlFor="settings-timezone">Таймзона</label>
|
||
<input
|
||
id="settings-timezone"
|
||
type="text"
|
||
list="timezone-options"
|
||
placeholder="Europe/Moscow"
|
||
value={timezone}
|
||
onChange={(e) => setTimezone(e.target.value)}
|
||
/>
|
||
<datalist id="timezone-options">
|
||
<option value="Europe/Moscow" />
|
||
<option value="Europe/Kaliningrad" />
|
||
<option value="Europe/Samara" />
|
||
<option value="Asia/Yekaterinburg" />
|
||
<option value="Asia/Novosibirsk" />
|
||
<option value="Asia/Krasnoyarsk" />
|
||
<option value="Asia/Irkutsk" />
|
||
<option value="Asia/Vladivostok" />
|
||
<option value="UTC" />
|
||
</datalist>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-card">
|
||
<h2>Контактный адрес</h2>
|
||
<p className="desc">
|
||
Адрес для ответов на письма от инстанса (уходят от no-reply — этот адрес подставляется
|
||
в заголовок «Reply-To»).
|
||
</p>
|
||
|
||
<div className="settings-card-body">
|
||
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
|
||
<div className="toggle-copy">
|
||
<strong>Контактный адрес включён</strong>
|
||
<span>Подставляется в Reply-To подтверждения регистрации, приглашений и саммари</span>
|
||
</div>
|
||
<label className="switch">
|
||
<input
|
||
type="checkbox"
|
||
checked={contactEmailEnabled}
|
||
onChange={(e) => setContactEmailEnabled(e.target.checked)}
|
||
/>
|
||
<span className="slider" />
|
||
</label>
|
||
</div>
|
||
|
||
<div className="field" style={{ marginBottom: 0, marginTop: 'var(--space-4)' }}>
|
||
<label htmlFor="settings-contact-email">Email</label>
|
||
<input
|
||
id="settings-contact-email"
|
||
type="email"
|
||
placeholder="contact@vidconf.ru"
|
||
value={contactEmail}
|
||
disabled={!contactEmailEnabled}
|
||
onChange={(e) => setContactEmail(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-card">
|
||
<h2>Тестовое письмо</h2>
|
||
<p className="desc">Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.</p>
|
||
|
||
<div className="settings-card-body">
|
||
<div className="field">
|
||
<label htmlFor="settings-test-email-to">Получатель</label>
|
||
<input
|
||
id="settings-test-email-to"
|
||
type="email"
|
||
placeholder={user?.email ?? 'you@example.com'}
|
||
value={testEmailTo}
|
||
onChange={(e) => setTestEmailTo(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
disabled={testEmailMutation.isPending}
|
||
onClick={() => testEmailMutation.mutate()}
|
||
>
|
||
{testEmailMutation.isPending ? 'Отправляем…' : 'Отправить тестовое письмо'}
|
||
</button>
|
||
|
||
{testEmailResult && (
|
||
<p
|
||
className="field-hint"
|
||
style={{ color: testEmailResult.success ? 'var(--color-success)' : 'var(--color-danger)' }}
|
||
>
|
||
{testEmailResult.message}
|
||
{testEmailResult.smtp_host && ` (${testEmailResult.smtp_host}:${testEmailResult.smtp_port})`}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<div className="settings-actions">
|
||
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
|
||
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|