E2 UI: журнал аудита — вкладка «Аудит» в админке пользователей
- AuditTab: события (вход/неудачный вход/блокировка/user_create/user_update/ password_reset/operation_*/contour_send_*), фильтр по типу, пагинация 50, retention из security_settings, field-level old→new в раскрываемых деталях. - Цветовая маркировка: вход/успех зелёным, отказы/ошибки красным, сброс пароля жёлтым.
This commit is contained in:
@@ -79,3 +79,36 @@
|
||||
.us-act-unlock{ background: #1f4d2c; color: #7ee2a8; }
|
||||
.us-act-pwd { background: #21262d; color: #e6edf3; }
|
||||
.us-empty { text-align: center; color: #8b949e; padding: 20px; }
|
||||
.us-tabs { display: flex; gap: 2px; }
|
||||
.us-tab {
|
||||
padding: 8px 16px; background: transparent; border: none; border-bottom: 2px solid transparent;
|
||||
color: #8b949e; font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.us-tab:hover { color: #e6edf3; }
|
||||
.us-tab-active { color: #e6edf3; border-bottom-color: #238636; }
|
||||
|
||||
.us-audit { margin-top: 4px; }
|
||||
.us-audit-meta { font-size: 12px; color: #8b949e; margin-left: auto; align-self: center; }
|
||||
.us-time { white-space: nowrap; font-family: monospace; font-size: 12px; color: #8b949e; }
|
||||
.us-ev {
|
||||
display: inline-block; padding: 2px 9px; border-radius: 9px; font-size: 11px; font-weight: 600;
|
||||
background: rgba(139, 148, 158, 0.15); color: #8b949e;
|
||||
}
|
||||
.us-ev-login, .us-ev-contour_send_ok { background: rgba(63, 185, 80, 0.15); color: #3fb950; }
|
||||
.us-ev-login_failed, .us-ev-locked_out, .us-ev-contour_send_failed {
|
||||
background: rgba(248, 81, 73, 0.15); color: #f85149;
|
||||
}
|
||||
.us-ev-user_create, .us-ev-operation_create { background: rgba(35, 134, 54, 0.2); color: #7ee2a8; }
|
||||
.us-ev-password_reset { background: rgba(255, 209, 102, 0.15); color: #ffd166; }
|
||||
.us-changes { margin-top: 6px; display: flex; flex-direction: column; gap: 3px; }
|
||||
.us-change { font-size: 12px; color: #e6edf3; }
|
||||
.us-change b { color: #8b949e; font-weight: 600; }
|
||||
.us-old { color: #f85149; text-decoration: line-through; }
|
||||
.us-new { color: #3fb950; }
|
||||
details summary { cursor: pointer; font-size: 12px; color: #58a6ff; }
|
||||
.us-pager { display: flex; gap: 12px; align-items: center; margin-top: 12px; font-size: 13px; color: #8b949e; }
|
||||
.us-pager button {
|
||||
padding: 6px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 6px;
|
||||
color: #e6edf3; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.us-pager button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
@@ -21,7 +21,120 @@ const STATUS_CLASS = {
|
||||
disabled: 'us-st-disabled',
|
||||
};
|
||||
|
||||
const EVENT_LABEL = {
|
||||
login: 'Вход',
|
||||
login_failed: 'Неудачный вход',
|
||||
logout: 'Выход',
|
||||
locked_out: 'Блокировка (5 неудач)',
|
||||
user_create: 'Создан пользователь',
|
||||
user_update: 'Изменён пользователь',
|
||||
password_reset: 'Сброс пароля',
|
||||
operation_create: 'Создана операция',
|
||||
operation_update: 'Изменена операция',
|
||||
contour_send_ok: 'Зоны отправлены в КОНТУР',
|
||||
contour_send_failed: 'Ошибка отправки в КОНТУР',
|
||||
};
|
||||
|
||||
const AuditTab = () => {
|
||||
const [events, setEvents] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [retention, setRetention] = useState(90);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [eventFilter, setEventFilter] = useState('');
|
||||
const [expanded, setExpanded] = useState({});
|
||||
const [offset, setOffset] = useState(0);
|
||||
const PAGE = 50;
|
||||
|
||||
const load = async (off = 0) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const params = new URLSearchParams({ limit: String(PAGE), offset: String(off) });
|
||||
if (eventFilter) params.set('event_type', eventFilter);
|
||||
const r = await apiFetch(`/api/v1/admin/users/audit?${params}`);
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
throw new Error(d.detail || `Ошибка ${r.status}`);
|
||||
}
|
||||
const d = await r.json();
|
||||
setEvents(d.items);
|
||||
setTotal(d.total);
|
||||
setRetention(d.retention_days);
|
||||
setOffset(off);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(0); }, [eventFilter]);
|
||||
|
||||
const fmtTime = (iso) => {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="us-audit">
|
||||
<div className="us-filters">
|
||||
<select value={eventFilter} onChange={(e) => setEventFilter(e.target.value)}>
|
||||
<option value="">Все события</option>
|
||||
{Object.entries(EVENT_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<span className="us-audit-meta">Хранится {retention} дней · всего {total}</span>
|
||||
<button className="us-reload" onClick={() => load(offset)}>↻</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="us-loading">Загрузка...</div>}
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<table className="us-table">
|
||||
<thead>
|
||||
<tr><th>Время</th><th>Пользователь</th><th>Событие</th><th>Объект</th><th>Изменения</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((ev) => (
|
||||
<tr key={ev.id}>
|
||||
<td className="us-time">{fmtTime(ev.created_at)}</td>
|
||||
<td>{ev.username || '—'}</td>
|
||||
<td><span className={`us-ev us-ev-${ev.event_type}`}>{EVENT_LABEL[ev.event_type] || ev.event_type}</span></td>
|
||||
<td>{ev.object_type || '—'}</td>
|
||||
<td>
|
||||
{ev.changes?.length ? (
|
||||
<details>
|
||||
<summary>{ev.changes.length} поле</summary>
|
||||
<div className="us-changes">
|
||||
{ev.changes.map((ch, i) => (
|
||||
<div key={i} className="us-change">
|
||||
<b>{ch.field}</b>: <span className="us-old">{ch.old ?? '∅'}</span> → <span className="us-new">{ch.new ?? '∅'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{events.length === 0 && <tr><td colSpan={5} className="us-empty">Событий нет</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div className="us-pager">
|
||||
<button disabled={offset === 0} onClick={() => load(Math.max(0, offset - PAGE))}>← Назад</button>
|
||||
<span>{offset + 1}–{Math.min(offset + PAGE, total)} из {total}</span>
|
||||
<button disabled={offset + PAGE >= total} onClick={() => load(offset + PAGE)}>Вперёд →</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UsersAdmin = () => {
|
||||
const [activeTab, setActiveTab] = useState('users'); // 'users' | 'audit'
|
||||
const [users, setUsers] = useState([]);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -147,12 +260,25 @@ const UsersAdmin = () => {
|
||||
return (
|
||||
<div className="users-admin">
|
||||
<div className="us-header">
|
||||
<h2>Пользователи</h2>
|
||||
<button className="us-create-btn" onClick={() => setShowCreate(!showCreate)}>
|
||||
{showCreate ? '— Отмена' : '+ Новый пользователь'}
|
||||
</button>
|
||||
<div className="us-tabs">
|
||||
<button
|
||||
className={`us-tab ${activeTab === 'users' ? 'us-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('users')}
|
||||
>Пользователи</button>
|
||||
<button
|
||||
className={`us-tab ${activeTab === 'audit' ? 'us-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('audit')}
|
||||
>Аудит</button>
|
||||
</div>
|
||||
{activeTab === 'users' && (
|
||||
<button className="us-create-btn" onClick={() => setShowCreate(!showCreate)}>
|
||||
{showCreate ? '— Отмена' : '+ Новый пользователь'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === 'audit' && <AuditTab />}
|
||||
{activeTab === 'users' && (<>
|
||||
{message && <div className={`us-message ${message.includes('Ошибка') || message.includes('не найден') ? 'us-message-err' : ''}`}>{message}</div>}
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
@@ -247,6 +373,7 @@ const UsersAdmin = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user