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:
@@ -0,0 +1,88 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ui/routes/admin.js — /admin, /audit (только для администратора).
|
||||
* /export — доступен всем аутентифицированным (смотри ui/routes/export.js).
|
||||
*
|
||||
* Данные из:
|
||||
* GET /api/v1/companies
|
||||
* PATCH /api/v1/companies/:id/limit
|
||||
* GET /api/v1/audit
|
||||
*/
|
||||
|
||||
const { Router } = require('express');
|
||||
const api = require('../api-client');
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.user || !req.user.isAdmin) {
|
||||
return res.status(403).render('error', {
|
||||
status: 403,
|
||||
message: 'Доступ запрещён — требуются права администратора.',
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function createRouter() {
|
||||
const router = Router();
|
||||
router.use(requireAdmin);
|
||||
|
||||
// GET /admin — список компаний с лимитами
|
||||
router.get('/admin', async (req, res) => {
|
||||
const token = api.token(req);
|
||||
try {
|
||||
const r = await api.get('/api/v1/companies', token);
|
||||
const companies = r.data.companies || [];
|
||||
res.render('admin', {
|
||||
user: req.user,
|
||||
companies,
|
||||
error: req.query.error || null,
|
||||
success: req.query.success || null,
|
||||
csrfToken: '',
|
||||
});
|
||||
} catch (e) {
|
||||
res.render('admin', {
|
||||
user: req.user, companies: [],
|
||||
error: 'Ошибка загрузки: ' + e.message, success: null, csrfToken: '',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /admin/limit/:id — установить лимит компании
|
||||
router.post('/admin/limit/:id', async (req, res) => {
|
||||
const token = api.token(req);
|
||||
const limit = parseInt(req.body.limit, 10);
|
||||
try {
|
||||
const r = await api.patch('/api/v1/companies/' + req.params.id + '/limit', token, { limit });
|
||||
if (r.status === 200) return res.redirect('/admin?success=' + encodeURIComponent('Лимит обновлён'));
|
||||
res.redirect('/admin?error=' + encodeURIComponent(r.data.error || 'Ошибка'));
|
||||
} catch (e) {
|
||||
res.redirect('/admin?error=' + encodeURIComponent(e.message));
|
||||
}
|
||||
});
|
||||
|
||||
// GET /audit — журнал действий
|
||||
router.get('/audit', async (req, res) => {
|
||||
const token = api.token(req);
|
||||
const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
|
||||
try {
|
||||
const r = await api.get('/api/v1/audit' + (companyId ? '?company=' + companyId : ''), token);
|
||||
res.render('audit', {
|
||||
user: req.user,
|
||||
rows: r.data.rows || [],
|
||||
companies: r.data.companies || [],
|
||||
selectedCompanyId: companyId,
|
||||
error: null,
|
||||
});
|
||||
} catch (e) {
|
||||
res.render('audit', {
|
||||
user: req.user, rows: [], companies: [],
|
||||
selectedCompanyId: null, error: 'Ошибка загрузки: ' + e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createRouter };
|
||||
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ui/routes/auth.js — /login, /logout для UI-слоя.
|
||||
*
|
||||
* Два режима:
|
||||
* Mock (DEV_MODE=true) — страница с пресетами MOCK_USERS.
|
||||
* Выбранный пользователь → генерируем mock Bearer token → session.token.
|
||||
*
|
||||
* Token (всегда доступен) — форма вставки реального Bearer токена от auth-api/KC.
|
||||
* Вставленный токен → session.token (без проверки подписи здесь,
|
||||
* API сам проверит при первом запросе).
|
||||
*
|
||||
* /logout — уничтожает сессию.
|
||||
*/
|
||||
|
||||
const { Router } = require('express');
|
||||
|
||||
function createRouter({ auth, MOCK_USERS, authLimiter }) {
|
||||
const router = Router();
|
||||
const devMode = () => process.env.DEV_MODE === 'true';
|
||||
|
||||
// GET /login
|
||||
router.get('/login', (req, res) => {
|
||||
if (req.session && req.session.token) return res.redirect('/');
|
||||
res.render('ui-login', {
|
||||
devMode: devMode(),
|
||||
MOCK_USERS: devMode() ? MOCK_USERS : [],
|
||||
error: req.query.error || null,
|
||||
returnTo: req.query.returnTo || '/',
|
||||
});
|
||||
});
|
||||
|
||||
// POST /login — mock user
|
||||
router.post('/login', authLimiter, (req, res) => {
|
||||
const { clientId, returnTo } = req.body;
|
||||
if (!clientId) return res.redirect('/login?error=Выберите+пользователя');
|
||||
|
||||
const user = MOCK_USERS.find(u => u.clientId === clientId);
|
||||
if (!user) return res.redirect('/login?error=Пользователь+не+найден');
|
||||
|
||||
const token = auth.issueMockToken({
|
||||
clientId: user.clientId,
|
||||
companyId: user.companyId || '00000000-0000-0000-0000-000000000001',
|
||||
companyName: user.companyName || user.clientId,
|
||||
email: user.email || user.clientId + '@mock.local',
|
||||
});
|
||||
|
||||
req.session.token = token;
|
||||
req.session.user = { clientId: user.clientId, email: user.email, isAdmin: user.isAdmin };
|
||||
res.redirect(returnTo || '/');
|
||||
});
|
||||
|
||||
// POST /login-token — вставить реальный Bearer токен
|
||||
router.post('/login-token', authLimiter, (req, res) => {
|
||||
const { token, returnTo } = req.body;
|
||||
if (!token || !token.trim()) return res.redirect('/login?error=Токен+не+может+быть+пустым');
|
||||
|
||||
req.session.token = token.trim();
|
||||
req.session.user = null; // заполнится при первом запросе к API
|
||||
res.redirect(returnTo || '/');
|
||||
});
|
||||
|
||||
// GET /logout
|
||||
router.get('/logout', (req, res) => {
|
||||
req.session.destroy(() => res.redirect('/login'));
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createRouter };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,31 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ui/routes/export.js — GET /export.
|
||||
* Доступен всем аутентифицированным (не только admin).
|
||||
* Проксирует GET /api/v1/entries/export (text/plain).
|
||||
*/
|
||||
|
||||
const { Router } = require('express');
|
||||
const api = require('../api-client');
|
||||
|
||||
function createRouter() {
|
||||
const router = Router();
|
||||
|
||||
router.get('/export', async (req, res) => {
|
||||
const token = api.token(req);
|
||||
const companyQuery = req.query.company ? '?company=' + parseInt(req.query.company, 10) : '';
|
||||
try {
|
||||
const r = await api.get('/api/v1/entries/export' + companyQuery, token);
|
||||
res.set('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.set('Content-Disposition', 'inline; filename="whitelist.txt"');
|
||||
res.send(r.data);
|
||||
} catch (e) {
|
||||
res.status(500).send('Ошибка экспорта: ' + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createRouter };
|
||||
Reference in New Issue
Block a user