From ab13a89617fd82d92af8a0c7db89a617985236c6 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Wed, 6 May 2026 12:42:23 +0500 Subject: [PATCH] =?UTF-8?q?feat(platform):=20UI=20/super-admin/platform-se?= =?UTF-8?q?ttings=20+=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D0=B0=D1=8F?= =?UTF-8?q?=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Страница SuperAdminPlatformSettingsPage в SuperAdmin консоли: - Форма SMTP: Host, Port, выбор шифрования (STARTTLS / Implicit TLS / без), Username, Password (placeholder показывает «(без изменений)» если уже сохранён), FromEmail (обязательно), FromName. - Поле «Причина изменения» (≥10 символов) — обязательно для PUT, пишется в SuperAdminAuditLog. - Блок «Тестовая отправка»: To/Subject/Body, кнопка реально шлёт через текущие сохранённые настройки, ответ сервера показывается зелёной/ красной плашкой с message (для SuperAdmin диагностический текст MailKit виден целиком). Добавлен пункт меню в SuperAdminLayout «SMTP / Email» в группе «Тех. обслуживание». Роут /super-admin/platform-settings зарегистрирован в App.tsx. --- src/food-market.web/src/App.tsx | 2 + .../src/components/SuperAdminLayout.tsx | 1 + .../pages/SuperAdminPlatformSettingsPage.tsx | 258 ++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 src/food-market.web/src/pages/SuperAdminPlatformSettingsPage.tsx diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 035473e..20a048e 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -35,6 +35,7 @@ import { TenantRouteGuard } from '@/components/TenantRouteGuard' import { ProtectedRoute } from '@/components/ProtectedRoute' import { NoOrganizationPage } from '@/pages/NoOrganizationPage' import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage' +import { SuperAdminPlatformSettingsPage } from '@/pages/SuperAdminPlatformSettingsPage' import { RoleGuard } from '@/components/RoleGuard' const queryClient = new QueryClient({ @@ -72,6 +73,7 @@ export default function App() { } /> } /> } /> + } /> {/* Tenant-роуты — обычный AppLayout, но с TenantRouteGuard: diff --git a/src/food-market.web/src/components/SuperAdminLayout.tsx b/src/food-market.web/src/components/SuperAdminLayout.tsx index f0b4b7a..3ec0c91 100644 --- a/src/food-market.web/src/components/SuperAdminLayout.tsx +++ b/src/food-market.web/src/components/SuperAdminLayout.tsx @@ -33,6 +33,7 @@ const NAV: NavSection[] = [ { to: '/super-admin/health', icon: HeartPulse, label: 'Здоровье', soon: true }, { to: '/super-admin/backups', icon: HardDriveDownload, label: 'Бэкапы', soon: true }, { to: '/super-admin/settings', icon: Settings, label: 'Системные настройки' }, + { to: '/super-admin/platform-settings', icon: Settings, label: 'SMTP / Email' }, ]}, ] diff --git a/src/food-market.web/src/pages/SuperAdminPlatformSettingsPage.tsx b/src/food-market.web/src/pages/SuperAdminPlatformSettingsPage.tsx new file mode 100644 index 0000000..fa86262 --- /dev/null +++ b/src/food-market.web/src/pages/SuperAdminPlatformSettingsPage.tsx @@ -0,0 +1,258 @@ +import { useEffect, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Save, Send, CheckCircle2, AlertTriangle, Mail } from 'lucide-react' +import { api } from '@/lib/api' +import { PageHeader } from '@/components/PageHeader' +import { Field, TextInput, TextArea, Checkbox } from '@/components/Field' +import { Button } from '@/components/Button' + +interface PlatformSettingsDto { + smtpHost: string | null + smtpPort: number | null + smtpUseSsl: boolean + smtpStartTls: boolean + smtpUsername: string | null + hasSmtpPassword: boolean + fromEmail: string | null + fromName: string | null + updatedAt: string | null +} + +interface Form { + smtpHost: string + smtpPort: string + smtpUseSsl: boolean + smtpStartTls: boolean + smtpUsername: string + newSmtpPassword: string + fromEmail: string + fromName: string + reason: string +} + +const blankForm = (): Form => ({ + smtpHost: '', smtpPort: '', smtpUseSsl: false, smtpStartTls: true, + smtpUsername: '', newSmtpPassword: '', + fromEmail: '', fromName: 'Food Market', + reason: '', +}) + +export function SuperAdminPlatformSettingsPage() { + const qc = useQueryClient() + const { data } = useQuery({ + queryKey: ['/api/super-admin/platform-settings'], + queryFn: async () => (await api.get('/api/super-admin/platform-settings')).data, + }) + const [form, setForm] = useState
(blankForm()) + const [loaded, setLoaded] = useState(false) + const [saving, setSaving] = useState(false) + const [savedHint, setSavedHint] = useState(false) + const [error, setError] = useState(null) + + // Тестовая отправка. + const [testTo, setTestTo] = useState('') + const [testSubject, setTestSubject] = useState('Тест Food Market') + const [testBody, setTestBody] = useState('Если вы видите это письмо — SMTP в Food Market настроен корректно.') + const [testBusy, setTestBusy] = useState(false) + const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null) + + useEffect(() => { + if (data && !loaded) { + setForm({ + smtpHost: data.smtpHost ?? '', + smtpPort: data.smtpPort != null ? String(data.smtpPort) : '', + smtpUseSsl: data.smtpUseSsl, + smtpStartTls: data.smtpStartTls, + smtpUsername: data.smtpUsername ?? '', + newSmtpPassword: '', + fromEmail: data.fromEmail ?? '', + fromName: data.fromName ?? 'Food Market', + reason: '', + }) + setLoaded(true) + } + }, [data, loaded]) + + const save = async () => { + setError(null); setSaving(true) + try { + await api.put('/api/super-admin/platform-settings', { + reason: form.reason, + smtpHost: form.smtpHost || null, + smtpPort: form.smtpPort ? Number(form.smtpPort) : null, + smtpUseSsl: form.smtpUseSsl, + smtpStartTls: form.smtpStartTls, + smtpUsername: form.smtpUsername || null, + newSmtpPassword: form.newSmtpPassword || null, + fromEmail: form.fromEmail || null, + fromName: form.fromName || null, + }) + await qc.invalidateQueries({ queryKey: ['/api/super-admin/platform-settings'] }) + setForm({ ...form, newSmtpPassword: '', reason: '' }) + setSavedHint(true) + setTimeout(() => setSavedHint(false), 2500) + } catch (e) { + const err = e as { response?: { data?: { error?: string } }, message?: string } + setError(err.response?.data?.error ?? err.message ?? 'Не удалось сохранить') + } finally { + setSaving(false) + } + } + + const sendTest = async () => { + setTestResult(null); setTestBusy(true) + try { + const res = await api.post<{ ok: boolean, sentTo?: string }>('/api/super-admin/platform-settings/test-send', { + toEmail: testTo, subject: testSubject, body: testBody, + }) + setTestResult({ ok: true, message: `Письмо отправлено на ${res.data.sentTo}. Проверьте почту получателя.` }) + } catch (e) { + const err = e as { response?: { data?: { error?: string } }, message?: string } + setTestResult({ ok: false, message: err.response?.data?.error ?? err.message ?? 'Не удалось отправить' }) + } finally { + setTestBusy(false) + } + } + + const reasonOk = form.reason.trim().length >= 10 + const requiredOk = form.smtpHost.trim() && form.fromEmail.trim() + + return ( +
+
+ + + {error && ( +
+ {error} +
+ )} + +
+

SMTP-сервер

+
+ + setForm({ ...form, smtpHost: e.target.value })} + placeholder="smtp.gmail.com" /> + + + setForm({ ...form, smtpPort: e.target.value })} placeholder="587" /> + + + + +
+ +
+ + setForm({ ...form, smtpUsername: e.target.value })} + placeholder="user@gmail.com" autoComplete="off" /> + + + setForm({ ...form, newSmtpPassword: e.target.value })} + placeholder={data?.hasSmtpPassword ? '•••••••• (без изменений)' : 'Введите пароль'} + autoComplete="new-password" /> +

+ {data?.hasSmtpPassword + ? 'Сохранён, шифрован DataProtection. Оставьте поле пустым — пароль не изменится.' + : 'Пароль не сохранён.'} +

+
+
+ +
+ + setForm({ ...form, fromEmail: e.target.value })} + placeholder="noreply@food-market.kz" /> + + + setForm({ ...form, fromName: e.target.value })} + placeholder="Food Market" /> + +
+
+ +
+

Сохранение

+ +