diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index f998e3d..520550b 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -34,6 +34,10 @@ export interface SettingsOut { registration_email_domain_enabled: boolean /** Эталонный домен для верификации (напр. `company.ru`) — `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. */ registration_email_domain_enabled?: boolean 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` + сведения о владельце. */ @@ -238,6 +260,14 @@ export async function updateAdminSettings(payload: SettingsUpdateIn): Promise('/admin/settings', { method: 'PUT', body: payload }) } +/** + * Отправить тестовое письмо синхронно — проверка почтовой конфигурации. + * Результат приходит в теле ответа (`success`/`message`), не через HTTP-статус ошибки. + */ +export async function sendTestEmail(payload: TestEmailIn = {}): Promise { + return apiRequest('/admin/settings/test-email', { method: 'POST', body: payload }) +} + /** Список команд для админки — с пагинацией. */ export async function listAdminTeams(query: AdminTeamQuery = {}): Promise> { const qs = toQueryString({ limit: query.limit, offset: query.offset }) diff --git a/frontend/src/components/admin/AdminSettingsTab.tsx b/frontend/src/components/admin/AdminSettingsTab.tsx index c417ad5..2d4d00f 100644 --- a/frontend/src/components/admin/AdminSettingsTab.tsx +++ b/frontend/src/components/admin/AdminSettingsTab.tsx @@ -1,9 +1,18 @@ import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 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 { ApiError, errorDetail } from '@/api/client' +import { useAuth } from '@/auth/useAuth' import { Select } from '@/components/ui/Select' import { useToast } from '@/components/ui/ToastProvider' @@ -42,6 +51,7 @@ export function AdminSettingsTab() { 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) @@ -51,6 +61,10 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice) const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled) 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(null) const mutation = useMutation({ 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() { mutation.mutate({ chat_enabled: chatEnabled, @@ -77,6 +104,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { registration_team_choice: teamChoiceEnabled, registration_email_domain_enabled: domainVerificationEnabled, registration_email_domain: emailDomain.trim() || null, + contact_email_enabled: contactEmailEnabled, + contact_email: contactEmail.trim() || null, }) } @@ -268,6 +297,80 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { +
+

Контактный адрес

+

+ Адрес для ответов на письма от инстанса (уходят от no-reply — этот адрес подставляется + в заголовок «Reply-To»). +

+ +
+
+
+ Контактный адрес включён + Подставляется в Reply-To подтверждения регистрации, приглашений и саммари +
+ +
+ +
+ + setContactEmail(e.target.value)} + /> +
+
+
+ +
+

Тестовое письмо

+

Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.

+ +
+
+ + setTestEmailTo(e.target.value)} + /> +
+ + + + {testEmailResult && ( +

+ {testEmailResult.message} + {testEmailResult.smtp_host && ` (${testEmailResult.smtp_host}:${testEmailResult.smtp_port})`} +

+ )} +
+
+