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:
parent
76e956ea6c
commit
ab13a89617
|
|
@ -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() {
|
|||
<Route path="groups" element={<ProductGroupsPage />} />
|
||||
<Route path="units" element={<UnitsOfMeasurePage />} />
|
||||
<Route path="settings" element={<SuperAdminSettingsPage />} />
|
||||
<Route path="platform-settings" element={<SuperAdminPlatformSettingsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Tenant-роуты — обычный AppLayout, но с TenantRouteGuard:
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]},
|
||||
]
|
||||
|
||||
|
|
|
|||
258
src/food-market.web/src/pages/SuperAdminPlatformSettingsPage.tsx
Normal file
258
src/food-market.web/src/pages/SuperAdminPlatformSettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue