diff --git a/docs/ТЗ-плюс.md b/docs/ТЗ-плюс.md index 374aecb..85e951e 100644 --- a/docs/ТЗ-плюс.md +++ b/docs/ТЗ-плюс.md @@ -195,6 +195,10 @@ IAM (Identity & Access Management) — сервис авторизации эк | 04.06.2026 | **IAM API — источник компаний.** `GET /auth/user` → `profiles[]`. `isAdmin` из `userInfo.isAdmin`. Переключение через `POST /switch-profile`. | | 04.06.2026 | ddos-guard: curl требует `--http2`, Node.js работает | +| 16.06.2026 | **UI: кнопка Показать/Скрыть удалённые.** Вместо чекбокса — кнопка-переключатель. | +| 16.06.2026 | **UI: восстановление удалённых записей.** Кнопка ↺ у удалённых, `POST /restore/:id`. Лимит проверяется. Аудит: RESTORE. | +| 16.06.2026 | **UI: время МСК.** `toLocaleString('ru', {timeZone:'Europe/Moscow'})` + метка «МСК». | + --- ## Легенда статусов diff --git a/package.json b/package.json index 9999156..0c74f44 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ipwhitelist", - "version": "0.5.133", + "version": "0.5.134", "description": "IP WhiteList microservice for cloud provider", "main": "server.js", "scripts": { diff --git a/v2/src/config/index.js b/v2/src/config/index.js index c889d13..649b1cd 100644 --- a/v2/src/config/index.js +++ b/v2/src/config/index.js @@ -9,7 +9,7 @@ // ═══════════════════════════════════════════════════════════════════════════════ module.exports = { - version: '0.5.133', + version: '0.5.134', // ── IAM ────────────────────────────────────────────────────────────────── iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user', diff --git a/v2/src/crud/index.js b/v2/src/crud/index.js index 4837e96..a276752 100644 --- a/v2/src/crud/index.js +++ b/v2/src/crud/index.js @@ -74,4 +74,11 @@ async function remove(entryId, clientId, email, impBy) { return q.deleteEntry(entryId, co.id, email, impBy); } -module.exports = { list, add, edit, remove }; +// ── restore(entryId, clientId, email, impBy) → void ───────────────────────── +// Восстановление удалённой записи. +async function restore(entryId, clientId, email, impBy) { + const co = await resolve(clientId); + return q.restoreEntry(entryId, co.id, email, impBy); +} + +module.exports = { list, add, edit, remove, restore }; diff --git a/v2/src/db/queries.js b/v2/src/db/queries.js index bcde74a..d79494b 100644 --- a/v2/src/db/queries.js +++ b/v2/src/db/queries.js @@ -177,6 +177,44 @@ async function deleteEntry(entryId, companyId, userEmail, impersonatedBy) { } } +/** + * Восстановить удалённую запись (убрать deleted_at). + */ +async function restoreEntry(entryId, companyId, userEmail, impersonatedBy) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const old = (await client.query( + 'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NOT NULL', + [entryId, companyId] + )).rows[0]; + if (!old) throw new Error('Удалённая запись не найдена'); + + await client.query( + 'UPDATE whitelist_entries SET deleted_by = NULL, deleted_at = NULL WHERE id = $1 AND company_id = $2', + [entryId, companyId] + ); + await logAudit(userEmail, companyId, 'RESTORE', old.value_cidr, old.value_cidr, entryId, impersonatedBy, client); + + // Проверить лимит + const company = (await client.query('SELECT * FROM companies WHERE id = $1', [companyId])).rows[0]; + const limit = company.custom_limit != null ? company.custom_limit : (parseInt(process.env.DEFAULT_LIMIT, 10) || 15); + const cnt = (await client.query( + 'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL', + [companyId] + )).rows[0].c; + if (cnt > limit) throw new Error('Восстановление превысит лимит (' + limit + ')'); + + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } +} + // ── Админ ───────────────────────────────────────────────────────────────────── async function getCompanyById(id) { @@ -241,7 +279,7 @@ async function getAudit(companyId = null) { module.exports = { getOrCreateCompany, getLimit, - listEntries, createEntry, updateEntry, deleteEntry, + listEntries, createEntry, updateEntry, deleteEntry, restoreEntry, getExportCIDRs, getAudit, getCompanyById, getAllCompanies, setLimit, }; diff --git a/v2/src/db/schema.js b/v2/src/db/schema.js index 1afed66..a04d1af 100644 --- a/v2/src/db/schema.js +++ b/v2/src/db/schema.js @@ -46,7 +46,7 @@ async function ensureSchema(pool) { id SERIAL PRIMARY KEY, user_email VARCHAR(255) NOT NULL, company_id INTEGER NOT NULL, - action VARCHAR(32) NOT NULL CHECK (action IN ('CREATE','UPDATE','DELETE')), + action VARCHAR(32) NOT NULL CHECK (action IN ('CREATE','UPDATE','DELETE','RESTORE')), old_value TEXT, new_value TEXT, entry_id INTEGER, @@ -56,6 +56,22 @@ async function ensureSchema(pool) { CREATE INDEX IF NOT EXISTS idx_audit_log_company_time ON audit_log(company_id, created_at DESC); + + -- Миграция: добавить RESTORE в CHECK (если ещё нет) + DO $$ + DECLARE + cn text; + BEGIN + SELECT con.conname INTO cn + FROM pg_constraint con + JOIN pg_class rel ON rel.oid = con.conrelid + WHERE rel.relname = 'audit_log' AND con.contype = 'c'; + IF cn IS NOT NULL THEN + EXECUTE 'ALTER TABLE audit_log DROP CONSTRAINT ' || cn; + END IF; + EXECUTE 'ALTER TABLE audit_log ADD CONSTRAINT audit_log_action_check CHECK (action IN (''CREATE'',''UPDATE'',''DELETE'',''RESTORE''))'; + END; + $$; `); console.log('[db] Schema ensured'); diff --git a/v2/src/user/index.js b/v2/src/user/index.js index 81705a9..eb3c54d 100644 --- a/v2/src/user/index.js +++ b/v2/src/user/index.js @@ -122,6 +122,16 @@ function createUserRouter() { } }); + // ── POST /restore/:id ────────────────────────────────────────────── + router.post('/restore/:id', async (req, res) => { + try { + await crud.restore(parseInt(req.params.id), req.clientId, req.email, req.impersonatedBy); + res.redirect('/v2/app?msg=Восстановлено'); + } catch (e) { + res.redirect('/v2/app?error=' + encodeURIComponent(e.message)); + } + }); + return router; } diff --git a/views/v2/user.ejs b/views/v2/user.ejs index 80ee604..420f990 100644 --- a/views/v2/user.ejs +++ b/views/v2/user.ejs @@ -149,10 +149,7 @@ <% }) %> - + <%= includeDeleted ? 'Скрыть удалённые' : 'Показать удалённые' %> <% if (user.isAdmin) { %> Режим: Администратор Лимиты и Аудит @@ -226,7 +223,7 @@
<%= e.value_cidr %><% if (e.deleted_at) { %> удалена<% } %>