Files
vidconf/frontend/src/components/admin/AdminSettingsTab.tsx
Max Ronzhin 25ffd9e678
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
feat(auth): несколько эталонных mail-доменов для верификации регистрации
Настройка «Эталон mail-домена» теперь хранит список доменов вместо
одного — email при регистрации принимается, если совпадает с любым из
них. Старое значение в БД ({"domain": str|None}) читается прозрачно
(обратная совместимость без Alembic-миграции) и переписывается в новую
форму ({"domains": [...]}) при первом же сохранении настроек. В админке
добавление/удаление доменов — списком чипов; на экране регистрации
подсказка о несовпадении домена перечисляет все эталонные варианты.
2026-07-27 20:40:23 +03:00

446 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}