463 lines
34 KiB
TypeScript
463 lines
34 KiB
TypeScript
|
|
import React, { useState, useEffect } from 'react';
|
|||
|
|
import { ChevronDown, ChevronRight, AlertTriangle, CheckCircle2, Shield, List, Ban, BarChart3, Loader2, RefreshCw, Plus, Trash2 } from 'lucide-react';
|
|||
|
|
import { backendApi } from '../../services/apiClient';
|
|||
|
|
|
|||
|
|
type BlockId = 'A' | 'B' | 'C' | 'D' | 'E' | 'F';
|
|||
|
|
|
|||
|
|
const BLOCKS: Array<{ id: BlockId; title: string; steps: Array<{ id: string; action: string; responsible: string; result: string }> }> = [
|
|||
|
|
{ id: 'A', title: 'Организация (152-ФЗ)', steps: [
|
|||
|
|
{ id: 'A1', action: 'Назначить ответственного за обработку ПДн (приказ руководителя, круг обязанностей)', responsible: 'Руководство', result: 'Приказ, ФИО ответственного' },
|
|||
|
|
{ id: 'A2', action: 'Разработать политику обработки персональных данных (цели, основания, состав ПДн, сроки хранения, права субъектов, меры защиты)', responsible: 'Ответственный + юрист/внешний консультант', result: 'Документ «Политика обработки ПДн»' },
|
|||
|
|
{ id: 'A3', action: 'Опубликовать политику (сайт, личный кабинет, приложение)', responsible: 'Ответственный / админ', result: 'Ссылка на политику доступна пользователям' },
|
|||
|
|
{ id: 'A4', action: 'Подготовить формы согласия на обработку ПДн (для сотрудников, жильцов, контактов в заявках; при спецкатегориях — отдельное согласие)', responsible: 'Ответственный + юрист', result: 'Формы согласий, чекбоксы в формах сбора данных' },
|
|||
|
|
{ id: 'A5', action: 'Внедрить сбор согласий при регистрации/добавлении сотрудников и контактов', responsible: 'Разработка', result: 'Согласие фиксируется при вводе ПДн' },
|
|||
|
|
{ id: 'A6', action: 'Подать уведомление в РКН о обработке ПДн (до начала обработки; цели, категории данных, меры защиты)', responsible: 'Ответственный', result: 'Подтверждение приёма уведомления РКН' },
|
|||
|
|
{ id: 'A7', action: 'Принять локальные акты: положение об обработке и защите ПДн, регламент доступа к БД и файлам, инструкция для администраторов', responsible: 'Ответственный', result: 'Приказы/положения в организации' },
|
|||
|
|
]},
|
|||
|
|
{ id: 'B', title: 'HTTPS и секреты (защита при передаче)', steps: [
|
|||
|
|
{ id: 'B1', action: 'Определить домен и поддомены; проверить доступ к DNS для DNS-01', responsible: 'Инфраструктура', result: 'Список доменов, доступ к DNS' },
|
|||
|
|
{ id: 'B2', action: 'Выпустить wildcard-сертификат (Let\'s Encrypt, DNS-01) или отдельные сертификаты; сохранить в volume/каталог для прокси', responsible: 'Инфраструктура', result: 'Сертификаты на диске/volume' },
|
|||
|
|
{ id: 'B3', action: 'Добавить reverse proxy (Traefik / Caddy / Nginx) в docker-compose: 443 и 80, редирект 80→443, проксирование на приложение по HTTP', responsible: 'Разработка / DevOps', result: 'Работающий прокси в Docker' },
|
|||
|
|
{ id: 'B4', action: 'Настроить HSTS (Strict-Transport-Security) в прокси', responsible: 'Разработка / DevOps', result: 'HSTS включён' },
|
|||
|
|
{ id: 'B5', action: 'В проде задать VITE_API_BASE_URL с https; собирать фронт при деплое с этим значением', responsible: 'Разработка / DevOps', result: 'Все запросы к API идут по HTTPS' },
|
|||
|
|
{ id: 'B6', action: 'Убрать дефолтный JWT_SECRET из кода: при отсутствии process.env.JWT_SECRET не запускать сервер; хранить секрет в env/секретах', responsible: 'Разработка', result: 'JWT_SECRET только из env' },
|
|||
|
|
{ id: 'B7', action: 'Настроить автообновление сертификатов и перезагрузку прокси после обновления', responsible: 'Инфраструктура', result: 'Сертификаты продлеваются автоматически' },
|
|||
|
|
]},
|
|||
|
|
{ id: 'C', title: 'Хранение и доступ', steps: [
|
|||
|
|
{ id: 'C1', action: 'Проверить, что пароли нигде не логируются; при создании/обновлении пользователя всегда bcrypt-хеш', responsible: 'Разработка', result: 'Аудит логов и кода auth' },
|
|||
|
|
{ id: 'C2', action: 'Ограничить доступ ОС к папке backend/uploads (только процесс приложения); при необходимости шифрование тома', responsible: 'Инфраструктура', result: 'Права на uploads проверены' },
|
|||
|
|
{ id: 'C3', action: 'Зафиксировать в регламенте доступ ролей к полям с ПДн; при необходимости не отдавать в API чувствительные поля', responsible: 'Ответственный + разработка', result: 'Регламент, при необходимости доработка API' },
|
|||
|
|
{ id: 'C4', action: 'Логирование доступа к критичным операциям (изменение сотрудника, экспорт, смена пароля) — без паролей и паспортных данных', responsible: 'Разработка', result: 'Аудит-логи на критические действия' },
|
|||
|
|
{ id: 'C5', action: 'Зафиксировать в политике ПДн сроки хранения; при необходимости удаление/обезличивание по истечении срока', responsible: 'Ответственный + разработка', result: 'Сроки в документе, при необходимости автоматизация' },
|
|||
|
|
{ id: 'C6', action: '(Опционально) Убрать хранение пароля Doma AI в localStorage: токен или proxy авторизации через бэкенд', responsible: 'Разработка', result: 'Пароль не в localStorage' },
|
|||
|
|
{ id: 'C7', action: '(Опционально) Шифрование чувствительных полей в БД (паспорт, СНИЛС, реквизиты); ключ вне БД', responsible: 'Разработка', result: 'Принято решение и при необходимости реализовано' },
|
|||
|
|
]},
|
|||
|
|
{ id: 'D', title: 'Реагирование и обучение', steps: [
|
|||
|
|
{ id: 'D1', action: 'Разработать порядок реагирования на инциденты (обнаружение, фиксация, уведомление РКН и субъектов ПДн, устранение причин)', responsible: 'Ответственный + юрист', result: 'Документ «Порядок реагирования на утечки ПДн»' },
|
|||
|
|
{ id: 'D2', action: 'Провести инструктаж сотрудников по работе с ПДн (запрет передачи паролей вне регламента, только HTTPS)', responsible: 'Ответственный', result: 'Журнал инструктажей / запись о проведении' },
|
|||
|
|
]},
|
|||
|
|
{ id: 'E', title: 'Защита от взлома', steps: [
|
|||
|
|
{ id: 'E1', action: 'Rate limit на /api/auth/login (блокировка или задержка после N неудачных попыток с одного IP/логина)', responsible: 'Разработка', result: 'Защита от брутфорса входа' },
|
|||
|
|
{ id: 'E2', action: 'Валидация ввода; параметризованные запросы к БД; защита от XSS (CSP) и CSRF (токены при необходимости)', responsible: 'Разработка', result: 'Снижение риска SQL-инъекций, XSS, CSRF' },
|
|||
|
|
{ id: 'E3', action: 'Защитные заголовки HTTP: X-Content-Type-Options, X-Frame-Options, Referrer-Policy, при необходимости CSP', responsible: 'Разработка / DevOps', result: 'Защита от XSS и clickjacking' },
|
|||
|
|
{ id: 'E4', action: 'Регулярная проверка уязвимостей (npm audit, Snyk) и обновление backend/frontend-пакетов; исправление критичных CVE', responsible: 'Разработка', result: 'Нет известных критичных уязвимостей' },
|
|||
|
|
{ id: 'E5', action: 'Firewall (только 80/443 снаружи, SSH по ключу или с ограничением IP); секреты только в env/vault', responsible: 'Инфраструктура', result: 'Сокращение поверхности атаки' },
|
|||
|
|
{ id: 'E6', action: 'Логирование неудачных входов и подозрительных запросов; при возможности алерты при аномалиях', responsible: 'Разработка / DevOps', result: 'Быстрое выявление попыток взлома' },
|
|||
|
|
]},
|
|||
|
|
{ id: 'F', title: 'Защита от вирусов и вредоносного ПО', steps: [
|
|||
|
|
{ id: 'F1', action: 'Антивирус на рабочих местах и серверах: регулярное сканирование, обновление баз', responsible: 'Инфраструктура / ИТ', result: 'Снижение риска заражения' },
|
|||
|
|
{ id: 'F2', action: 'Проверка загружаемых файлов: ограничение типов и размера; при возможности сканирование при загрузке (ClamAV)', responsible: 'Разработка / DevOps', result: 'Загрузки не становятся каналом вирусов' },
|
|||
|
|
{ id: 'F3', action: 'Обновление ОС и ПО: патчи хоста и образов Docker; Node, PostgreSQL и прокси до поддерживаемых версий', responsible: 'Инфраструктура', result: 'Меньше уязвимостей в стеке' },
|
|||
|
|
{ id: 'F4', action: 'Не запускать непроверенные скрипты; зависимости только из доверенных реестров (npm); при необходимости проверка целостности', responsible: 'Разработка', result: 'Снижение риска supply-chain атак' },
|
|||
|
|
{ id: 'F5', action: 'Политика для сотрудников: не открывать подозрительные вложения, не ставить неподтверждённое ПО; ограничение прав учёток', responsible: 'Ответственный / ИТ', result: 'Меньше риска заноса вирусов' },
|
|||
|
|
]},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
type TabId = 'dashboard' | 'plan';
|
|||
|
|
|
|||
|
|
function formatDate(iso: string) {
|
|||
|
|
try {
|
|||
|
|
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|||
|
|
} catch {
|
|||
|
|
return iso;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const SecuritySection: React.FC = () => {
|
|||
|
|
const [tab, setTab] = useState<TabId>('dashboard');
|
|||
|
|
const [expanded, setExpanded] = useState<Set<BlockId>>(new Set(['A', 'B']));
|
|||
|
|
|
|||
|
|
// Дашборд: настройки, статистика, логи, чёрный список
|
|||
|
|
const [settings, setSettings] = useState<{ captchaEnabled: boolean; turnstileSiteKey: string | null; turnstileSecretKeySet: boolean } | null>(null);
|
|||
|
|
const [stats, setStats] = useState<{ failedLast24h: number; totalAttemptsLast24h: number; blockedCount: number } | null>(null);
|
|||
|
|
const [logs, setLogs] = useState<{ items: Array<{ id: number; ip: string; loginMasked: string; success: boolean; createdAt: string }>; total: number; page: number; limit: number } | null>(null);
|
|||
|
|
const [blacklist, setBlacklist] = useState<Array<{ id: number; type: string; value: string; reason: string | null; createdAt: string }>>([]);
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
const [logsPage, setLogsPage] = useState(1);
|
|||
|
|
const [logsSuccessFilter, setLogsSuccessFilter] = useState<'all' | true | false>('all');
|
|||
|
|
const [blacklistForm, setBlacklistForm] = useState({ type: 'ip' as 'ip' | 'login', value: '', reason: '' });
|
|||
|
|
const [blacklistSubmitting, setBlacklistSubmitting] = useState(false);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
// Форма настройки капчи (синхронизируем site key из settings при загрузке)
|
|||
|
|
const [captchaSiteKey, setCaptchaSiteKey] = useState('');
|
|||
|
|
const [captchaSecretKey, setCaptchaSecretKey] = useState('');
|
|||
|
|
const [captchaSaving, setCaptchaSaving] = useState(false);
|
|||
|
|
|
|||
|
|
const loadDashboard = async () => {
|
|||
|
|
setLoading(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
const [settingsRes, statsRes, logsRes, blacklistRes] = await Promise.all([
|
|||
|
|
backendApi.getSecuritySettings(),
|
|||
|
|
backendApi.getSecurityStats(),
|
|||
|
|
backendApi.getSecurityLogs({ page: logsPage, limit: 20, success: logsSuccessFilter === 'all' ? undefined : logsSuccessFilter }),
|
|||
|
|
backendApi.getSecurityBlacklist(),
|
|||
|
|
]);
|
|||
|
|
setSettings(settingsRes);
|
|||
|
|
setCaptchaSiteKey(settingsRes.turnstileSiteKey || '');
|
|||
|
|
setStats(statsRes);
|
|||
|
|
setLogs(logsRes);
|
|||
|
|
setBlacklist(blacklistRes);
|
|||
|
|
} catch (e: unknown) {
|
|||
|
|
setError((e as Error)?.message || 'Ошибка загрузки дашборда');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (tab === 'dashboard') loadDashboard();
|
|||
|
|
}, [tab, logsPage, logsSuccessFilter]);
|
|||
|
|
|
|||
|
|
const toggle = (id: BlockId) => {
|
|||
|
|
setExpanded((prev) => {
|
|||
|
|
const next = new Set(prev);
|
|||
|
|
if (next.has(id)) next.delete(id);
|
|||
|
|
else next.add(id);
|
|||
|
|
return next;
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleAddBlacklist = async () => {
|
|||
|
|
const val = blacklistForm.value.trim();
|
|||
|
|
if (!val) return;
|
|||
|
|
setBlacklistSubmitting(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
await backendApi.addSecurityBlacklist({ type: blacklistForm.type, value: val, reason: blacklistForm.reason.trim() || undefined });
|
|||
|
|
setBlacklistForm({ ...blacklistForm, value: '', reason: '' });
|
|||
|
|
await loadDashboard();
|
|||
|
|
} catch (e: unknown) {
|
|||
|
|
setError((e as Error)?.message || 'Ошибка добавления');
|
|||
|
|
} finally {
|
|||
|
|
setBlacklistSubmitting(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDeleteBlacklist = async (id: number) => {
|
|||
|
|
try {
|
|||
|
|
await backendApi.deleteSecurityBlacklist(id);
|
|||
|
|
setBlacklist((prev) => prev.filter((r) => r.id !== id));
|
|||
|
|
} catch (e: unknown) {
|
|||
|
|
setError((e as Error)?.message || 'Ошибка удаления');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleSaveCaptcha = async () => {
|
|||
|
|
setCaptchaSaving(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
const res = await backendApi.saveSecuritySettings({
|
|||
|
|
turnstileSiteKey: captchaSiteKey.trim() || undefined,
|
|||
|
|
turnstileSecretKey: captchaSecretKey.trim() || undefined,
|
|||
|
|
});
|
|||
|
|
setSettings(res);
|
|||
|
|
setCaptchaSiteKey(res.turnstileSiteKey || '');
|
|||
|
|
setCaptchaSecretKey('');
|
|||
|
|
await loadDashboard();
|
|||
|
|
} catch (e: unknown) {
|
|||
|
|
setError((e as Error)?.message || 'Ошибка сохранения');
|
|||
|
|
} finally {
|
|||
|
|
setCaptchaSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
<div className="flex gap-2 border-b border-slate-200">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setTab('dashboard')}
|
|||
|
|
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${tab === 'dashboard' ? 'bg-slate-100 text-slate-800 border border-slate-200 border-b-0 -mb-px' : 'text-slate-600 hover:bg-slate-50'}`}
|
|||
|
|
>
|
|||
|
|
<Shield className="w-4 h-4" />
|
|||
|
|
Дашборд безопасности
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setTab('plan')}
|
|||
|
|
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${tab === 'plan' ? 'bg-slate-100 text-slate-800 border border-slate-200 border-b-0 -mb-px' : 'text-slate-600 hover:bg-slate-50'}`}
|
|||
|
|
>
|
|||
|
|
<List className="w-4 h-4" />
|
|||
|
|
План (152-ФЗ)
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{tab === 'dashboard' && (
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
{error && (
|
|||
|
|
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-red-700 text-sm flex items-center justify-between">
|
|||
|
|
<span>{error}</span>
|
|||
|
|
<button type="button" onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<h3 className="text-lg font-bold text-slate-800">Мониторинг и настройки</h3>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={loadDashboard}
|
|||
|
|
disabled={loading}
|
|||
|
|
className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100 rounded-lg"
|
|||
|
|
>
|
|||
|
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
|||
|
|
Обновить
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{loading && !settings ? (
|
|||
|
|
<div className="flex items-center justify-center py-12"><Loader2 className="w-8 h-8 animate-spin text-slate-400" /></div>
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
{/* Настройка капчи Turnstile */}
|
|||
|
|
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
|||
|
|
<div className="flex items-center gap-2 text-slate-500 text-sm mb-3">
|
|||
|
|
<Shield className="w-4 h-4" /> Капча (Turnstile)
|
|||
|
|
</div>
|
|||
|
|
<div className="flex flex-wrap items-end gap-3 mb-2">
|
|||
|
|
<span className="font-semibold text-slate-800">
|
|||
|
|
{settings?.captchaEnabled ? 'Включена' : 'Выключена'}
|
|||
|
|
</span>
|
|||
|
|
<span className="text-xs text-slate-500">
|
|||
|
|
Задайте ключи из <a href="https://dash.cloudflare.com/?to=/:account/turnstile" target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">Cloudflare Turnstile</a> и сохраните — виджет появится на форме входа.
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex flex-wrap gap-3 items-end">
|
|||
|
|
<label className="flex flex-col gap-1">
|
|||
|
|
<span className="text-xs font-medium text-slate-600">Site key (публичный)</span>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={captchaSiteKey}
|
|||
|
|
onChange={(e) => setCaptchaSiteKey(e.target.value)}
|
|||
|
|
placeholder="0x4AAAAAAA..."
|
|||
|
|
className="text-sm border border-slate-200 rounded-lg px-3 py-2 min-w-[200px] font-mono"
|
|||
|
|
/>
|
|||
|
|
</label>
|
|||
|
|
<label className="flex flex-col gap-1">
|
|||
|
|
<span className="text-xs font-medium text-slate-600">Secret key</span>
|
|||
|
|
<input
|
|||
|
|
type="password"
|
|||
|
|
value={captchaSecretKey}
|
|||
|
|
onChange={(e) => setCaptchaSecretKey(e.target.value)}
|
|||
|
|
placeholder={settings?.turnstileSecretKeySet ? 'Оставьте пустым, чтобы не менять' : 'Секретный ключ'}
|
|||
|
|
className="text-sm border border-slate-200 rounded-lg px-3 py-2 min-w-[200px] font-mono"
|
|||
|
|
/>
|
|||
|
|
</label>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={handleSaveCaptcha}
|
|||
|
|
disabled={captchaSaving}
|
|||
|
|
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
{captchaSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
|||
|
|
Сохранить
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Статистика */}
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|||
|
|
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
|||
|
|
<div className="flex items-center gap-2 text-slate-500 text-sm mb-1"><BarChart3 className="w-4 h-4" /> Неудачных входов за 24 ч</div>
|
|||
|
|
<div className="font-semibold text-slate-800">{stats?.failedLast24h ?? '—'}</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
|||
|
|
<div className="flex items-center gap-2 text-slate-500 text-sm mb-1"><BarChart3 className="w-4 h-4" /> Всего попыток за 24 ч</div>
|
|||
|
|
<div className="font-semibold text-slate-800">{stats?.totalAttemptsLast24h ?? '—'}</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
|||
|
|
<div className="flex items-center gap-2 text-slate-500 text-sm mb-1"><Ban className="w-4 h-4" /> В чёрном списке</div>
|
|||
|
|
<div className="font-semibold text-slate-800">{stats?.blockedCount ?? '—'}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Логи входа */}
|
|||
|
|
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
|||
|
|
<div className="p-4 border-b border-slate-100 flex flex-wrap items-center gap-3">
|
|||
|
|
<h4 className="font-semibold text-slate-800">Логи входа</h4>
|
|||
|
|
<select
|
|||
|
|
value={logsSuccessFilter === 'all' ? 'all' : logsSuccessFilter ? 'true' : 'false'}
|
|||
|
|
onChange={(e) => { const v = e.target.value; setLogsSuccessFilter(v === 'all' ? 'all' : v === 'true'); setLogsPage(1); }}
|
|||
|
|
className="text-sm border border-slate-200 rounded-lg px-2 py-1"
|
|||
|
|
>
|
|||
|
|
<option value="all">Все</option>
|
|||
|
|
<option value="true">Успешные</option>
|
|||
|
|
<option value="false">Неудачные</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div className="overflow-x-auto">
|
|||
|
|
<table className="w-full text-sm">
|
|||
|
|
<thead>
|
|||
|
|
<tr className="bg-slate-50 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
|||
|
|
<th className="px-4 py-2">IP</th>
|
|||
|
|
<th className="px-4 py-2">Логин (маска)</th>
|
|||
|
|
<th className="px-4 py-2">Результат</th>
|
|||
|
|
<th className="px-4 py-2">Время</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{logs?.items?.length ? logs.items.map((row) => (
|
|||
|
|
<tr key={row.id} className="border-t border-slate-100">
|
|||
|
|
<td className="px-4 py-2 font-mono text-slate-700">{row.ip}</td>
|
|||
|
|
<td className="px-4 py-2 text-slate-600">{row.loginMasked || '—'}</td>
|
|||
|
|
<td className="px-4 py-2">
|
|||
|
|
{row.success ? <span className="text-emerald-600">Успех</span> : <span className="text-red-600">Ошибка</span>}
|
|||
|
|
</td>
|
|||
|
|
<td className="px-4 py-2 text-slate-500">{formatDate(row.createdAt)}</td>
|
|||
|
|
</tr>
|
|||
|
|
)) : (
|
|||
|
|
<tr><td colSpan={4} className="px-4 py-6 text-center text-slate-500">Нет записей</td></tr>
|
|||
|
|
)}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
{logs && logs.total > logs.limit && (
|
|||
|
|
<div className="p-3 border-t border-slate-100 flex items-center justify-between text-sm text-slate-600">
|
|||
|
|
<span>Всего: {logs.total}</span>
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<button type="button" disabled={logsPage <= 1} onClick={() => setLogsPage((p) => p - 1)} className="px-2 py-1 rounded border border-slate-200 disabled:opacity-50">Назад</button>
|
|||
|
|
<span>Стр. {logsPage}</span>
|
|||
|
|
<button type="button" disabled={logsPage * logs.limit >= logs.total} onClick={() => setLogsPage((p) => p + 1)} className="px-2 py-1 rounded border border-slate-200 disabled:opacity-50">Вперёд</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Чёрный список */}
|
|||
|
|
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
|||
|
|
<div className="p-4 border-b border-slate-100">
|
|||
|
|
<h4 className="font-semibold text-slate-800 mb-3">Чёрный список (IP или логин)</h4>
|
|||
|
|
<div className="flex flex-wrap gap-2 items-end">
|
|||
|
|
<select
|
|||
|
|
value={blacklistForm.type}
|
|||
|
|
onChange={(e) => setBlacklistForm({ ...blacklistForm, type: e.target.value as 'ip' | 'login' })}
|
|||
|
|
className="text-sm border border-slate-200 rounded-lg px-3 py-2"
|
|||
|
|
>
|
|||
|
|
<option value="ip">IP</option>
|
|||
|
|
<option value="login">Логин</option>
|
|||
|
|
</select>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={blacklistForm.value}
|
|||
|
|
onChange={(e) => setBlacklistForm({ ...blacklistForm, value: e.target.value })}
|
|||
|
|
placeholder={blacklistForm.type === 'ip' ? '192.168.1.1' : 'логин'}
|
|||
|
|
className="text-sm border border-slate-200 rounded-lg px-3 py-2 min-w-[140px]"
|
|||
|
|
/>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={blacklistForm.reason}
|
|||
|
|
onChange={(e) => setBlacklistForm({ ...blacklistForm, reason: e.target.value })}
|
|||
|
|
placeholder="Причина (необяз.)"
|
|||
|
|
className="text-sm border border-slate-200 rounded-lg px-3 py-2 min-w-[120px]"
|
|||
|
|
/>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={handleAddBlacklist}
|
|||
|
|
disabled={!blacklistForm.value.trim() || blacklistSubmitting}
|
|||
|
|
className="flex items-center gap-1 px-3 py-2 bg-slate-800 text-white text-sm font-medium rounded-lg hover:bg-slate-700 disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
{blacklistSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
|||
|
|
Добавить
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="overflow-x-auto">
|
|||
|
|
<table className="w-full text-sm">
|
|||
|
|
<thead>
|
|||
|
|
<tr className="bg-slate-50 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
|||
|
|
<th className="px-4 py-2">Тип</th>
|
|||
|
|
<th className="px-4 py-2">Значение</th>
|
|||
|
|
<th className="px-4 py-2">Причина</th>
|
|||
|
|
<th className="px-4 py-2">Добавлено</th>
|
|||
|
|
<th className="px-4 py-2 w-20"></th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{blacklist.length ? blacklist.map((row) => (
|
|||
|
|
<tr key={row.id} className="border-t border-slate-100">
|
|||
|
|
<td className="px-4 py-2 font-mono text-slate-600">{row.type}</td>
|
|||
|
|
<td className="px-4 py-2 text-slate-800">{row.value}</td>
|
|||
|
|
<td className="px-4 py-2 text-slate-500">{row.reason || '—'}</td>
|
|||
|
|
<td className="px-4 py-2 text-slate-500">{formatDate(row.createdAt)}</td>
|
|||
|
|
<td className="px-4 py-2">
|
|||
|
|
<button type="button" onClick={() => handleDeleteBlacklist(row.id)} className="p-1 text-red-600 hover:bg-red-50 rounded" title="Удалить"><Trash2 className="w-4 h-4" /></button>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
)) : (
|
|||
|
|
<tr><td colSpan={5} className="px-4 py-6 text-center text-slate-500">Пусто</td></tr>
|
|||
|
|
)}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{tab === 'plan' && (
|
|||
|
|
<>
|
|||
|
|
<p className="text-sm text-slate-600">
|
|||
|
|
План защиты персональных данных (152-ФЗ), HTTPS, шифрование при передаче и хранении, защита от взлома и от вирусов. Выполняйте шаги по порядку; приоритеты указаны ниже.
|
|||
|
|
</p>
|
|||
|
|
<div className="rounded-xl border border-amber-200 bg-amber-50/80 p-4">
|
|||
|
|
<h3 className="text-xs font-bold uppercase tracking-wider text-amber-800 mb-2 flex items-center gap-2"><AlertTriangle className="w-4 h-4" /> Приоритеты</h3>
|
|||
|
|
<ul className="text-sm text-slate-700 space-y-1">
|
|||
|
|
<li><strong>Критично:</strong> A1–A2, A6, B3–B7 (HTTPS + JWT_SECRET). Без этого высоки риски штрафов и компрометации.</li>
|
|||
|
|
<li><strong>Важно:</strong> A3–A5, A7, C1–C4, E1–E3, E5, F1, F3. Требования 152-ФЗ, аудит, базовая защита от взлома и вирусов.</li>
|
|||
|
|
<li><strong>Далее:</strong> C5–C7, D1–D2, E4, E6, F2, F4, F5.</li>
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{BLOCKS.map((block) => {
|
|||
|
|
const isOpen = expanded.has(block.id);
|
|||
|
|
return (
|
|||
|
|
<div key={block.id} className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
|||
|
|
<button type="button" onClick={() => toggle(block.id)} className="w-full flex items-center gap-2 py-3 px-4 text-left font-semibold text-slate-800 hover:bg-slate-50 transition-colors">
|
|||
|
|
{isOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
|||
|
|
<span className="text-xs uppercase tracking-wider text-slate-500">Блок {block.id}</span>
|
|||
|
|
<span className="ml-1">{block.title}</span>
|
|||
|
|
</button>
|
|||
|
|
{isOpen && (
|
|||
|
|
<div className="border-t border-slate-100 p-4 bg-slate-50/50">
|
|||
|
|
<div className="overflow-x-auto">
|
|||
|
|
<table className="w-full text-sm border-collapse">
|
|||
|
|
<thead>
|
|||
|
|
<tr className="text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
|||
|
|
<th className="pb-2 pr-4">№</th>
|
|||
|
|
<th className="pb-2 pr-4">Действие</th>
|
|||
|
|
<th className="pb-2 pr-4 whitespace-nowrap">Ответственный</th>
|
|||
|
|
<th className="pb-2">Результат</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{block.steps.map((step) => (
|
|||
|
|
<tr key={step.id} className="border-t border-slate-100 first:border-t-0">
|
|||
|
|
<td className="py-2 pr-4 font-mono text-slate-600">{step.id}</td>
|
|||
|
|
<td className="py-2 pr-4 text-slate-700">{step.action}</td>
|
|||
|
|
<td className="py-2 pr-4 text-slate-600 whitespace-nowrap">{step.responsible}</td>
|
|||
|
|
<td className="py-2 text-slate-600">{step.result}</td>
|
|||
|
|
</tr>
|
|||
|
|
))}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4 flex items-start gap-3">
|
|||
|
|
<CheckCircle2 className="w-5 h-5 text-emerald-600 shrink-0 mt-0.5" />
|
|||
|
|
<div className="text-sm text-slate-600">
|
|||
|
|
<p className="font-medium text-slate-800 mb-1">Шифрование по закону</p>
|
|||
|
|
<p>Передача: TLS (HTTPS) везде. Пароли: bcrypt (уже используется). Хранение в БД: разграничение доступа; при повышенных рисках — шифрование чувствительных полей. Биометрия не используется — ст. 19 572-ФЗ не применяется.</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|