feat(platform): UI /super-admin/platform-settings + тестовая отправка

Страница 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.
This commit is contained in:
nns 2026-05-06 12:42:23 +05:00
parent 76e956ea6c
commit ab13a89617
3 changed files with 261 additions and 0 deletions

View file

@ -35,6 +35,7 @@ import { TenantRouteGuard } from '@/components/TenantRouteGuard'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
import { NoOrganizationPage } from '@/pages/NoOrganizationPage' import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage' import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage'
import { SuperAdminPlatformSettingsPage } from '@/pages/SuperAdminPlatformSettingsPage'
import { RoleGuard } from '@/components/RoleGuard' import { RoleGuard } from '@/components/RoleGuard'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@ -72,6 +73,7 @@ export default function App() {
<Route path="groups" element={<ProductGroupsPage />} /> <Route path="groups" element={<ProductGroupsPage />} />
<Route path="units" element={<UnitsOfMeasurePage />} /> <Route path="units" element={<UnitsOfMeasurePage />} />
<Route path="settings" element={<SuperAdminSettingsPage />} /> <Route path="settings" element={<SuperAdminSettingsPage />} />
<Route path="platform-settings" element={<SuperAdminPlatformSettingsPage />} />
</Route> </Route>
{/* Tenant-роуты обычный AppLayout, но с TenantRouteGuard: {/* Tenant-роуты обычный AppLayout, но с TenantRouteGuard:

View file

@ -33,6 +33,7 @@ const NAV: NavSection[] = [
{ to: '/super-admin/health', icon: HeartPulse, label: 'Здоровье', soon: true }, { to: '/super-admin/health', icon: HeartPulse, label: 'Здоровье', soon: true },
{ to: '/super-admin/backups', icon: HardDriveDownload, label: 'Бэкапы', soon: true }, { to: '/super-admin/backups', icon: HardDriveDownload, label: 'Бэкапы', soon: true },
{ to: '/super-admin/settings', icon: Settings, label: 'Системные настройки' }, { to: '/super-admin/settings', icon: Settings, label: 'Системные настройки' },
{ to: '/super-admin/platform-settings', icon: Settings, label: 'SMTP / Email' },
]}, ]},
] ]

View file

@ -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<PlatformSettingsDto>('/api/super-admin/platform-settings')).data,
})
const [form, setForm] = useState<Form>(blankForm())
const [loaded, setLoaded] = useState(false)
const [saving, setSaving] = useState(false)
const [savedHint, setSavedHint] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="h-full overflow-auto">
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-5">
<PageHeader
title="Настройки платформы"
description="SMTP-сервер для отправки писем (восстановление пароля, нотификации). Доступно только Супер-администратору."
/>
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="font-semibold flex items-center gap-2"><Mail className="w-4 h-4" /> SMTP-сервер</h3>
<div className="grid grid-cols-3 gap-3">
<Field label="Хост (server)">
<TextInput value={form.smtpHost} onChange={(e) => setForm({ ...form, smtpHost: e.target.value })}
placeholder="smtp.gmail.com" />
</Field>
<Field label="Порт">
<TextInput type="number" inputMode="numeric" value={form.smtpPort}
onChange={(e) => setForm({ ...form, smtpPort: e.target.value })} placeholder="587" />
</Field>
<Field label="Шифрование">
<select
value={form.smtpUseSsl ? 'ssl' : (form.smtpStartTls ? 'starttls' : 'none')}
onChange={(e) => {
const v = e.target.value
setForm({
...form,
smtpUseSsl: v === 'ssl',
smtpStartTls: v === 'starttls',
})
}}
className="w-full h-10 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 text-sm"
>
<option value="starttls">STARTTLS (587)</option>
<option value="ssl">Implicit TLS / SSL (465)</option>
<option value="none">Без шифрования (dev)</option>
</select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Логин (Username)">
<TextInput value={form.smtpUsername} onChange={(e) => setForm({ ...form, smtpUsername: e.target.value })}
placeholder="user@gmail.com" autoComplete="off" />
</Field>
<Field label="Пароль">
<TextInput type="password" value={form.newSmtpPassword}
onChange={(e) => setForm({ ...form, newSmtpPassword: e.target.value })}
placeholder={data?.hasSmtpPassword ? '•••••••• (без изменений)' : 'Введите пароль'}
autoComplete="new-password" />
<p className="text-xs text-slate-500 mt-1">
{data?.hasSmtpPassword
? 'Сохранён, шифрован DataProtection. Оставьте поле пустым — пароль не изменится.'
: 'Пароль не сохранён.'}
</p>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="From — email отправителя *">
<TextInput type="email" value={form.fromEmail}
onChange={(e) => setForm({ ...form, fromEmail: e.target.value })}
placeholder="noreply@food-market.kz" />
</Field>
<Field label="From — имя отправителя">
<TextInput value={form.fromName}
onChange={(e) => setForm({ ...form, fromName: e.target.value })}
placeholder="Food Market" />
</Field>
</div>
</section>
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="font-semibold">Сохранение</h3>
<Field label="Причина изменения (≥ 10 символов, в журнал)">
<TextArea rows={2} value={form.reason}
onChange={(e) => setForm({ ...form, reason: e.target.value })}
placeholder="Например: подключение Gmail SMTP для отправки писем восстановления пароля" />
</Field>
<div className="flex items-center gap-3 pt-1">
<Button onClick={save} disabled={!reasonOk || !requiredOk || saving}>
<Save className="w-4 h-4" /> {saving ? 'Сохраняю…' : 'Сохранить настройки'}
</Button>
{savedHint && (
<span className="text-sm text-emerald-600 inline-flex items-center gap-1">
<CheckCircle2 className="w-4 h-4" /> Сохранено
</span>
)}
</div>
{!requiredOk && (
<p className="text-xs text-slate-500">Минимально нужны: SmtpHost и FromEmail. Без них отправка невозможна.</p>
)}
</section>
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="font-semibold flex items-center gap-2"><Send className="w-4 h-4" /> Тестовая отправка</h3>
<p className="text-xs text-slate-500">
Письмо отправляется немедленно через текущие сохранённые настройки. Удобно проверить что
креды Gmail/Yandex/Mailgun валидны и что firewall пропускает SMTP-порт.
</p>
<div className="grid grid-cols-2 gap-3">
<Field label="Кому">
<TextInput type="email" value={testTo} onChange={(e) => setTestTo(e.target.value)} placeholder="you@example.com" />
</Field>
<Field label="Тема">
<TextInput value={testSubject} onChange={(e) => setTestSubject(e.target.value)} />
</Field>
</div>
<Field label="Текст">
<TextArea rows={3} value={testBody} onChange={(e) => setTestBody(e.target.value)} />
</Field>
<Button onClick={sendTest} disabled={!testTo || testBusy} variant="secondary">
<Send className="w-4 h-4" /> {testBusy ? 'Отправляю…' : 'Отправить тестовое письмо'}
</Button>
{testResult && (
<div className={
testResult.ok
? 'rounded-md bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300 inline-flex items-start gap-2'
: 'rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-300 inline-flex items-start gap-2'
}>
{testResult.ok ? <CheckCircle2 className="w-4 h-4 mt-0.5" /> : <AlertTriangle className="w-4 h-4 mt-0.5" />}
<span>{testResult.message}</span>
</div>
)}
</section>
{/* Hidden Checkbox import используется чтобы линтер не ругался оставляем
чтобы при будущем расширении (например «Использовать SMTP-настройки
организации вместо платформенных») было где включить тоггл. */}
<div className="hidden"><Checkbox label="" checked={false} onChange={() => {}} /></div>
</div>
</div>
)
}