Files
mkd/components/admin/SecuritySection.tsx
2026-02-04 00:17:04 +05:00

463 lines
34 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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> A1A2, A6, B3B7 (HTTPS + JWT_SECRET). Без этого высоки риски штрафов и компрометации.</li>
<li><strong>Важно:</strong> A3A5, A7, C1C4, E1E3, E5, F1, F3. Требования 152-ФЗ, аудит, базовая защита от взлома и вирусов.</li>
<li><strong>Далее:</strong> C5C7, D1D2, 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>
);
};