feat: UI-слой через /api/v1/* (ui/, views/ui-login.ejs, views/error.ejs)

- ui/api-client.js: HTTP-клиент к /api/v1/* (token из session.token)
- ui/routes/auth.js: /login (mock-пресеты), /login-token (Bearer paste), /logout
- ui/routes/entries.js: /, /add, /edit/:id, /delete/:id через API
- ui/routes/admin.js: /admin, /admin/limit/:id, /audit через API
- ui/routes/export.js: /export → проксирует /api/v1/entries/export
- ui/index.js: createUiRouter() — requireToken + resolveUser middleware
- views/ui-login.ejs: страница входа (mock + token)
- views/error.ejs: шаблон для 403/404/500

API расширения:
- GET /api/v1/entries теперь возвращает limit и used
- GET /api/v1/entries?company=<id> (admin): записи другой компании
- POST/PATCH/DELETE /api/v1/entries?company=<id> (admin): мутации для другой компании
- GET /api/v1/entries/export: агрегированный CIDR (перенесён из монолита)
- GET /api/v1/audit?company=<id>: журнал (перенесён из монолита)

server.js: заменены src/routes/* на ui/createUiRouter(), убран CSRF
This commit is contained in:
2026-05-30 19:02:51 +03:00
parent 87216389e3
commit c85a438ba2
11 changed files with 699 additions and 81 deletions
+132
View File
@@ -0,0 +1,132 @@
'use strict';
/**
* ui/routes/entries.js — главная страница: список записей + CRUD.
*
* Данные из /api/v1/entries (Bearer token из сессии).
* Admin: поддержка ?company=<id> (числовой PK) для управления другой компанией.
* Формы с company_id скрытым полем → передаётся в API как ?company=<id>.
*/
const { Router } = require('express');
const api = require('../api-client');
function createRouter() {
const router = Router();
// Вычислить URL для возврата (с учётом выбранной компании)
function backUrl(req) {
const companyId = req.query.company || (req.body && req.body.company_id);
return companyId ? '/?company=' + companyId : '/';
}
// Суффикс ?company=<id> для вызовов API
function companyQuery(req) {
const companyId = req.query.company || (req.body && req.body.company_id);
return companyId ? '?company=' + companyId : '';
}
// GET / — список записей
router.get('/', async (req, res) => {
const token = api.token(req);
try {
let entries = [], limit = 15, selectedCompany = null, companies = [];
if (req.user.isAdmin) {
const cr = await api.get('/api/v1/companies', token);
companies = cr.data.companies || [];
const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
selectedCompany = companyId ? companies.find(c => c.id === companyId) || null : null;
if (selectedCompany) {
const er = await api.get('/api/v1/entries?company=' + companyId, token);
entries = er.data.entries || [];
limit = er.data.limit || selectedCompany.custom_limit || selectedCompany.default_limit || 15;
}
} else {
const er = await api.get('/api/v1/entries', token);
entries = er.data.entries || [];
limit = er.data.limit || 15;
}
res.render('index', {
entries,
limit,
used: entries.length,
user: req.user,
isAdmin: req.user.isAdmin,
companies,
selectedCompany,
error: req.query.error || null,
success: req.query.success || null,
csrfToken: '',
});
} catch (e) {
res.render('index', {
entries: [], limit: 15, used: 0,
user: req.user, isAdmin: req.user.isAdmin,
companies: [], selectedCompany: null,
error: 'Ошибка загрузки данных: ' + e.message,
success: null, csrfToken: '',
});
}
});
// POST /add — создать запись
router.post('/add', async (req, res) => {
const token = api.token(req);
const { value, comment } = req.body;
const cq = companyQuery(req);
const back = backUrl(req);
try {
const r = await api.post('/api/v1/entries' + cq, token, { value, comment });
if (r.status === 201) {
const msg = r.data.wasNormalized
? 'Запись добавлена (CIDR нормализован до ' + r.data.entry.value_cidr + ')'
: 'Запись добавлена';
return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent(msg));
}
res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(r.data.error || 'Ошибка'));
} catch (e) {
res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(e.message));
}
});
// POST /edit/:id — обновить запись
router.post('/edit/:id', async (req, res) => {
const token = api.token(req);
const { value, comment } = req.body;
const cq = companyQuery(req);
const back = backUrl(req);
try {
const r = await api.patch('/api/v1/entries/' + req.params.id + cq, token, { value, comment });
if (r.status === 200) {
return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent('Запись обновлена'));
}
res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(r.data.error || 'Ошибка'));
} catch (e) {
res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(e.message));
}
});
// POST /delete/:id — удалить запись
router.post('/delete/:id', async (req, res) => {
const token = api.token(req);
const cq = companyQuery(req);
const back = backUrl(req);
try {
const r = await api.delete('/api/v1/entries/' + req.params.id + cq, token);
if (r.status === 204) {
return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent('Запись удалена'));
}
res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent((r.data && r.data.error) || 'Ошибка'));
} catch (e) {
res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(e.message));
}
});
return router;
}
module.exports = { createRouter };