feat(admin): контактный адрес и тестовое письмо в настройках админки
Поле включения/адреса в карточке «Контактный адрес» и кнопка «Отправить тестовое письмо» с выводом результата — рядом, по образцу соседних настроек AdminSettingsTab.
This commit is contained in:
@@ -34,6 +34,10 @@ export interface SettingsOut {
|
|||||||
registration_email_domain_enabled: boolean
|
registration_email_domain_enabled: boolean
|
||||||
/** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */
|
/** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */
|
||||||
registration_email_domain: string | null
|
registration_email_domain: string | null
|
||||||
|
/** Включён ли контактный адрес инстанса (подставляется в `Reply-To` исходящих писем). */
|
||||||
|
contact_email_enabled: boolean
|
||||||
|
/** Контактный адрес — `null`, если не задан/выключен. */
|
||||||
|
contact_email: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
|
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
|
||||||
@@ -48,6 +52,24 @@ export interface SettingsUpdateIn {
|
|||||||
/** Включение без домена или невалидный домен — backend отвечает 400. */
|
/** Включение без домена или невалидный домен — backend отвечает 400. */
|
||||||
registration_email_domain_enabled?: boolean
|
registration_email_domain_enabled?: boolean
|
||||||
registration_email_domain?: string | null
|
registration_email_domain?: string | null
|
||||||
|
/** Включение без email или невалидный email — backend отвечает 400. */
|
||||||
|
contact_email_enabled?: boolean
|
||||||
|
contact_email?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */
|
||||||
|
export interface TestEmailIn {
|
||||||
|
/** Не задан — backend отправит на email текущего администратора. */
|
||||||
|
to?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Результат тестовой отправки — успех или текст ошибки транспорта. */
|
||||||
|
export interface TestEmailOut {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
/** Хост/порт SMTP — только при `EMAIL_BACKEND=smtp`, без логина/пароля. */
|
||||||
|
smtp_host: string | null
|
||||||
|
smtp_port: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Конференция в ответе админ-списка — `ConferenceOut` + сведения о владельце. */
|
/** Конференция в ответе админ-списка — `ConferenceOut` + сведения о владельце. */
|
||||||
@@ -238,6 +260,14 @@ export async function updateAdminSettings(payload: SettingsUpdateIn): Promise<Se
|
|||||||
return apiRequest<SettingsOut>('/admin/settings', { method: 'PUT', body: payload })
|
return apiRequest<SettingsOut>('/admin/settings', { method: 'PUT', body: payload })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправить тестовое письмо синхронно — проверка почтовой конфигурации.
|
||||||
|
* Результат приходит в теле ответа (`success`/`message`), не через HTTP-статус ошибки.
|
||||||
|
*/
|
||||||
|
export async function sendTestEmail(payload: TestEmailIn = {}): Promise<TestEmailOut> {
|
||||||
|
return apiRequest<TestEmailOut>('/admin/settings/test-email', { method: 'POST', body: payload })
|
||||||
|
}
|
||||||
|
|
||||||
/** Список команд для админки — с пагинацией. */
|
/** Список команд для админки — с пагинацией. */
|
||||||
export async function listAdminTeams(query: AdminTeamQuery = {}): Promise<PagedResult<TeamOut>> {
|
export async function listAdminTeams(query: AdminTeamQuery = {}): Promise<PagedResult<TeamOut>> {
|
||||||
const qs = toQueryString({ limit: query.limit, offset: query.offset })
|
const qs = toQueryString({ limit: query.limit, offset: query.offset })
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { AlertTriangle } from 'lucide-react'
|
import { AlertTriangle } from 'lucide-react'
|
||||||
import { getAdminSettings, updateAdminSettings, type AiLevel, type SettingsOut, type SettingsUpdateIn } from '@/api/admin'
|
import {
|
||||||
|
getAdminSettings,
|
||||||
|
sendTestEmail,
|
||||||
|
updateAdminSettings,
|
||||||
|
type AiLevel,
|
||||||
|
type SettingsOut,
|
||||||
|
type SettingsUpdateIn,
|
||||||
|
type TestEmailOut,
|
||||||
|
} from '@/api/admin'
|
||||||
import type { SummaryRecipientsMode } from '@/api/conferences'
|
import type { SummaryRecipientsMode } from '@/api/conferences'
|
||||||
import { ApiError, errorDetail } from '@/api/client'
|
import { ApiError, errorDetail } from '@/api/client'
|
||||||
|
import { useAuth } from '@/auth/useAuth'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { useToast } from '@/components/ui/ToastProvider'
|
import { useToast } from '@/components/ui/ToastProvider'
|
||||||
|
|
||||||
@@ -42,6 +51,7 @@ export function AdminSettingsTab() {
|
|||||||
function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
|
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
|
||||||
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
|
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
|
||||||
@@ -51,6 +61,10 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
|||||||
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
|
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
|
||||||
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
|
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
|
||||||
const [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '')
|
const [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '')
|
||||||
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload),
|
mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload),
|
||||||
@@ -67,6 +81,19 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 handleSave() {
|
function handleSave() {
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
chat_enabled: chatEnabled,
|
chat_enabled: chatEnabled,
|
||||||
@@ -77,6 +104,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
|||||||
registration_team_choice: teamChoiceEnabled,
|
registration_team_choice: teamChoiceEnabled,
|
||||||
registration_email_domain_enabled: domainVerificationEnabled,
|
registration_email_domain_enabled: domainVerificationEnabled,
|
||||||
registration_email_domain: emailDomain.trim() || null,
|
registration_email_domain: emailDomain.trim() || null,
|
||||||
|
contact_email_enabled: contactEmailEnabled,
|
||||||
|
contact_email: contactEmail.trim() || null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +297,80 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<div className="settings-actions">
|
||||||
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
|
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
|
||||||
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}
|
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}
|
||||||
|
|||||||
Reference in New Issue
Block a user