fix: три бага — CSRF в админке, пагинация аудита, скрыта кнопка выхода
1. Изменение лимитов (Баг 1): добавлен CSRF-токен в форму /v2/admin/limit
- v2/src/admin/index.js: createAdminRouter принимает {generateCsrfToken},
передаёт csrfToken в шаблон admin.ejs
- views/v2/admin.ejs: скрытое поле <input name=_csrf> в форме лимита
2. Журнал операций (Баг 2): пагинация + фильтр по дате вместо хардкод LIMIT 500
- v2/src/db/queries.js: getAudit({companyId,days,page,limit}) —
COUNT(*) OVER() для пагинации, WHERE created_at > NOW() - INTERVAL
- v2/src/admin/index.js: чтение days/page из query string, limit=100
- views/v2/admin-audit.ejs: фильтр 30/90/365 дней, prev/next пагинация
3. Кнопка Выйти (Баг 3): скрыта, т.к. /v2/logout не разлогинивает из Keycloak
- views/v2/user.ejs: кнопка закомментирована с пояснением
v0.1.8 → v0.1.9
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ipwhitelist",
|
"name": "ipwhitelist",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"description": "IP WhiteList microservice for cloud provider",
|
"description": "IP WhiteList microservice for cloud provider",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+15
-5
@@ -13,7 +13,7 @@ const express = require('express');
|
|||||||
const q = require('../db/queries');
|
const q = require('../db/queries');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
|
||||||
function createAdminRouter() {
|
function createAdminRouter({ generateCsrfToken } = {}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.use((req, res, next) => {
|
router.use((req, res, next) => {
|
||||||
@@ -30,19 +30,29 @@ function createAdminRouter() {
|
|||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const companies = await q.getAllCompanies();
|
const companies = await q.getAllCompanies();
|
||||||
res.render('v2/admin', { companies, user: req.adminUser, version: config.version });
|
res.render('v2/admin', {
|
||||||
|
companies, user: req.adminUser, version: config.version,
|
||||||
|
csrfToken: generateCsrfToken ? generateCsrfToken(req, res) : '',
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre>');
|
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre>');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GET /audit ──────────────────────────────────────────────────────
|
// ── GET /audit — журнал операций с пагинацией и фильтром по дате ───
|
||||||
router.get('/audit', async (req, res) => {
|
router.get('/audit', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const companyId = req.query.companyId ? parseInt(req.query.companyId) : null;
|
const companyId = req.query.companyId ? parseInt(req.query.companyId) : null;
|
||||||
const audit = await q.getAudit(companyId);
|
const days = Math.min(Math.max(parseInt(req.query.days) || 365, 1), 365);
|
||||||
|
const page = Math.max(parseInt(req.query.page) || 1, 1);
|
||||||
|
const perPage = 100;
|
||||||
|
const result = await q.getAudit({ companyId, days, page, limit: perPage });
|
||||||
const company = companyId ? await q.getCompanyById(companyId) : null;
|
const company = companyId ? await q.getCompanyById(companyId) : null;
|
||||||
res.render('v2/admin-audit', { audit, company, version: config.version });
|
res.render('v2/admin-audit', {
|
||||||
|
audit: result.rows, total: result.total,
|
||||||
|
page, pages: Math.ceil(result.total / perPage) || 1,
|
||||||
|
days, company, version: config.version,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre>');
|
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre>');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
version: '0.1.8',
|
version: '0.1.9',
|
||||||
|
|
||||||
// ── IAM ──────────────────────────────────────────────────────────────────
|
// ── IAM ──────────────────────────────────────────────────────────────────
|
||||||
iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user',
|
iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user',
|
||||||
|
|||||||
+38
-7
@@ -265,16 +265,47 @@ async function logAudit(userEmail, companyId, action, oldValue, newValue, entryI
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAudit(companyId = null) {
|
/**
|
||||||
let sql = `
|
* Журнал операций с фильтром по дате и пагинацией.
|
||||||
SELECT a.*, c.name AS company_name, c.client_id
|
* @param {Object} opts
|
||||||
|
* @param {number|null} opts.companyId — фильтр по компании (null = все)
|
||||||
|
* @param {number} opts.days — глубина в днях (по умолчанию 365)
|
||||||
|
* @param {number} opts.page — страница (с 1)
|
||||||
|
* @param {number} opts.limit — записей на странице
|
||||||
|
* @returns {{ rows: Array, total: number }}
|
||||||
|
*/
|
||||||
|
async function getAudit({ companyId = null, days = 365, page = 1, limit = 100 } = {}) {
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
let where = '';
|
||||||
|
if (companyId) {
|
||||||
|
where += ' WHERE a.company_id = $' + (params.length + 1);
|
||||||
|
params.push(companyId);
|
||||||
|
}
|
||||||
|
const dayParam = '$' + (params.length + 1);
|
||||||
|
where += (where ? ' AND' : ' WHERE') + ' a.created_at > NOW() - (' + dayParam + ' || \' days\')::INTERVAL';
|
||||||
|
params.push(String(days));
|
||||||
|
|
||||||
|
const limitIdx = params.length + 1;
|
||||||
|
const offsetIdx = params.length + 2;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT a.*, c.name AS company_name, c.client_id,
|
||||||
|
COUNT(*) OVER() AS total_count
|
||||||
FROM audit_log a
|
FROM audit_log a
|
||||||
JOIN companies c ON c.id = a.company_id
|
JOIN companies c ON c.id = a.company_id
|
||||||
|
${where}
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT $${limitIdx} OFFSET $${offsetIdx}
|
||||||
`;
|
`;
|
||||||
const params = [];
|
params.push(limit, offset);
|
||||||
if (companyId) { sql += ' WHERE a.company_id = $1'; params.push(companyId); }
|
|
||||||
sql += ' ORDER BY a.created_at DESC LIMIT 500';
|
const result = await pool.query(sql, params);
|
||||||
return (await pool.query(sql, params)).rows;
|
return {
|
||||||
|
rows: result.rows,
|
||||||
|
total: result.rows.length > 0 ? parseInt(result.rows[0].total_count, 10) : 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -73,9 +73,21 @@
|
|||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">Журнал операций <span style="font-weight:400;font-size:.82rem;color:var(--muted);">(макс 500)</span></div>
|
<div class="card-header">
|
||||||
|
Журнал операций
|
||||||
|
<span style="font-weight:400;font-size:.82rem;color:var(--muted);">
|
||||||
|
(<%= total %> записей за <%= days %> дн.)
|
||||||
|
</span>
|
||||||
|
<span style="float:right;display:flex;gap:.3rem;">
|
||||||
|
<% [30, 90, 365].forEach(function(d) { %>
|
||||||
|
<a href="?<%= company ? 'companyId=' + company.id + '&' : '' %>days=<%= d %>"
|
||||||
|
class="btn btn-sm<%= days === d ? ' btn-primary' : '' %>"
|
||||||
|
style="padding:.2rem .5rem;font-size:.75rem;"><%= d %>д</a>
|
||||||
|
<% }) %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<% if (audit.length === 0) { %>
|
<% if (audit.length === 0) { %>
|
||||||
<div class="empty">Нет записей</div>
|
<div class="empty">Нет записей за выбранный период</div>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
@@ -103,6 +115,23 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if (pages > 1) { %>
|
||||||
|
<div style="padding:.75rem 1rem;display:flex;justify-content:center;align-items:center;gap:.75rem;border-top:1px solid var(--border);">
|
||||||
|
<% if (page > 1) { %>
|
||||||
|
<a href="?<%= company ? 'companyId=' + company.id + '&' : '' %>days=<%= days %>&page=<%= page - 1 %>" class="btn btn-sm">← Назад</a>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="btn btn-sm" style="opacity:.4;cursor:default;">← Назад</span>
|
||||||
|
<% } %>
|
||||||
|
<span style="font-size:.85rem;color:var(--muted);">стр. <%= page %> из <%= pages %></span>
|
||||||
|
<% if (page < pages) { %>
|
||||||
|
<a href="?<%= company ? 'companyId=' + company.id + '&' : '' %>days=<%= days %>&page=<%= page + 1 %>" class="btn btn-sm">Вперёд →</a>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="btn btn-sm" style="opacity:.4;cursor:default;">Вперёд →</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -121,6 +121,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="POST" action="/v2/admin/limit" style="display:inline-flex;gap:.4rem;align-items:center;">
|
<form method="POST" action="/v2/admin/limit" style="display:inline-flex;gap:.4rem;align-items:center;">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
<input type="hidden" name="companyId" value="<%= c.id %>">
|
<input type="hidden" name="companyId" value="<%= c.id %>">
|
||||||
<input class="limit-input" name="limit" type="number" min="1" value="<%= c.custom_limit || 15 %>">
|
<input class="limit-input" name="limit" type="number" min="1" value="<%= c.custom_limit || 15 %>">
|
||||||
<button class="btn btn-sm btn-primary">установить</button>
|
<button class="btn btn-sm btn-primary">установить</button>
|
||||||
|
|||||||
@@ -124,7 +124,11 @@
|
|||||||
|
|
||||||
<span style="margin-left:auto;display:flex;gap:.5rem;align-items:center;">
|
<span style="margin-left:auto;display:flex;gap:.5rem;align-items:center;">
|
||||||
<span style="font-weight:500;color:var(--text);"><%= user.email %></span>
|
<span style="font-weight:500;color:var(--text);"><%= user.email %></span>
|
||||||
|
<!-- Кнопка выхода скрыта: /v2/logout не разлогинивает из Keycloak (только сессия Express).
|
||||||
|
Правильный OIDC-логаут с редиректом на KC — в src/routes/oidc.js:/logout.
|
||||||
|
Когда будет реализован полноценный SSO-логаут — раскомментировать.
|
||||||
<a href="/v2/logout" class="btn btn-sm">Выйти<% if (user.isImpersonated && user.originalUserEmail) { %> (<%= user.originalUserEmail %>)<% } %></a>
|
<a href="/v2/logout" class="btn btn-sm">Выйти<% if (user.isImpersonated && user.originalUserEmail) { %> (<%= user.originalUserEmail %>)<% } %></a>
|
||||||
|
-->
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user