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:
@@ -23,14 +23,13 @@ const helmet = require('helmet');
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const { checkConnection } = require('./src/db');
|
const { checkConnection } = require('./src/db');
|
||||||
const { initAuth, requireAdmin } = require('./src/auth');
|
const { initAuth } = require('./src/auth');
|
||||||
const { aggregateCIDRs } = require('./src/validators');
|
|
||||||
const q = require('./src/queries');
|
const q = require('./src/queries');
|
||||||
const { MOCK_USERS, backUrl, APP_VERSION } = require('./src/config');
|
const { MOCK_USERS, APP_VERSION } = require('./src/config');
|
||||||
const { mutationLimiter, exportLimiter, authLimiter } = require('./src/middleware/rateLimit');
|
const { authLimiter } = require('./src/middleware/rateLimit');
|
||||||
const { initCsrf } = require('./src/middleware/csrf');
|
|
||||||
const { createSessionMiddleware } = require('./src/middleware/session');
|
const { createSessionMiddleware } = require('./src/middleware/session');
|
||||||
const { CSP_DIRECTIVES } = require('./src/middleware/csp');
|
const { CSP_DIRECTIVES } = require('./src/middleware/csp');
|
||||||
|
const { createUiRouter } = require('./ui/index');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -57,34 +56,18 @@ async function start() {
|
|||||||
const auth = await initAuth();
|
const auth = await initAuth();
|
||||||
|
|
||||||
// ── Сессии ────────────────────────────────────────────────────────────────
|
// ── Сессии ────────────────────────────────────────────────────────────────
|
||||||
// PG-стор: сессии переживают рестарт, работают на нескольких подах.
|
|
||||||
// Таблица `session` создаётся автоматически при первом старте.
|
|
||||||
// src/middleware/session.js содержит логику и комментарии.
|
|
||||||
const { pool } = require('./src/db');
|
const { pool } = require('./src/db');
|
||||||
app.use(createSessionMiddleware(pool));
|
app.use(createSessionMiddleware(pool));
|
||||||
|
|
||||||
// ── CSRF защита ────────────────────────────────────────────────────────────
|
|
||||||
// initCsrf() читает CSRF_SECRET и NODE_ENV из process.env.
|
|
||||||
// Возвращает: doubleCsrfProtection (middleware на POST) и
|
|
||||||
// generateCsrfToken (вызов в GET-роутах для передачи токена в шаблон).
|
|
||||||
const { doubleCsrfProtection, generateCsrfToken } = initCsrf();
|
|
||||||
|
|
||||||
// ── Публичные маршруты (до auth.middleware) ──────────────────────────────────
|
|
||||||
|
|
||||||
// ── Fail-fast в production ────────────────────────────────────────────────
|
// ── Fail-fast в production ────────────────────────────────────────────────
|
||||||
// Если секреты не заданы — приложение в проде немедленно падает.
|
// Если секреты не заданы — приложение в проде немедленно падает.
|
||||||
// Дефолтные значения — только для локальной разработки.
|
// Дефолтные значения — только для локальной разработки.
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
const DEFAULT_SESSION = 'dev-session-secret-change-me';
|
const DEFAULT_SESSION = 'dev-session-secret-change-me';
|
||||||
const DEFAULT_CSRF = 'dev-csrf-secret-change-in-prod';
|
|
||||||
if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === DEFAULT_SESSION) {
|
if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === DEFAULT_SESSION) {
|
||||||
console.error('FATAL: SESSION_SECRET не задан или равен дефолту в production!');
|
console.error('FATAL: SESSION_SECRET не задан или равен дефолту в production!');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (!process.env.CSRF_SECRET || process.env.CSRF_SECRET === DEFAULT_CSRF) {
|
|
||||||
console.error('FATAL: CSRF_SECRET не задан или равен дефолту в production!');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// k8s liveness probe — простая проверка что сервер жив
|
// k8s liveness probe — простая проверка что сервер жив
|
||||||
@@ -93,65 +76,18 @@ async function start() {
|
|||||||
// JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме)
|
// JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме)
|
||||||
if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler);
|
if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler);
|
||||||
|
|
||||||
// GET /login, POST /login, GET /logout.
|
|
||||||
// src/routes/auth.js содержит логику и комментарии.
|
|
||||||
app.use(require('./src/routes/auth').createRouter({
|
|
||||||
auth,
|
|
||||||
doubleCsrfProtection,
|
|
||||||
generateCsrfToken,
|
|
||||||
MOCK_USERS,
|
|
||||||
authLimiter,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ── REST API v1 (Bearer JWT, без сессий/CSRF) ─────────────────────────────
|
// ── REST API v1 (Bearer JWT, без сессий/CSRF) ─────────────────────────────
|
||||||
const { createApiRouter } = require('./src/api/index');
|
const { createApiRouter } = require('./src/api/index');
|
||||||
app.use('/api/v1', createApiRouter({ auth, q }));
|
app.use('/api/v1', createApiRouter({ auth, q }));
|
||||||
|
|
||||||
// ── Все маршруты ниже требуют авторизации (auth.middleware) ────────────────
|
// ── UI-слой: SSR через EJS, данные из /api/v1/* ───────────────────────────
|
||||||
app.use(auth.middleware);
|
// Монтируется ПОСЛЕ /api/v1/ — не перехватывает API-запросы.
|
||||||
|
// Хранит Bearer token в сессии, рендерит те же views/*.ejs.
|
||||||
|
app.use('/', createUiRouter({ auth, MOCK_USERS, authLimiter }));
|
||||||
|
|
||||||
// GET /export: CIDR текущего пользователя (или всех компаний для admin).
|
// ── Общий обработчик ошибок ───────────────────────────────────────────────
|
||||||
// Перемещён за auth.middleware — изоляция данных по компании.
|
|
||||||
// src/routes/export.js содержит логику и комментарии.
|
|
||||||
app.use(require('./src/routes/export').createRouter({
|
|
||||||
q,
|
|
||||||
exportLimiter,
|
|
||||||
aggregateCIDRs,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// GET /, POST /add, POST /edit/:id, POST /delete/:id.
|
|
||||||
// src/routes/entries.js содержит логику и комментарии.
|
|
||||||
app.use(require('./src/routes/entries').createRouter({
|
|
||||||
q,
|
|
||||||
doubleCsrfProtection,
|
|
||||||
mutationLimiter,
|
|
||||||
generateCsrfToken,
|
|
||||||
backUrl,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// GET /audit, GET /admin, POST /admin/limit/:companyId.
|
|
||||||
// src/routes/admin.js содержит логику и комментарии.
|
|
||||||
app.use(require('./src/routes/admin').createRouter({
|
|
||||||
q,
|
|
||||||
requireAdmin,
|
|
||||||
doubleCsrfProtection,
|
|
||||||
mutationLimiter,
|
|
||||||
generateCsrfToken,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ── CSRF error handler ────────────────────────────────────────────────────────
|
|
||||||
// doubleCsrfProtection вызывает next(err) с кодом 403 при невалидном токене.
|
|
||||||
// Ловим ДО общего обработчика — возвращаем понятное сообщение пользователю.
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
if (err && (err.status === 403 || err.code === 'EBADCSRFTOKEN')) {
|
|
||||||
return res.status(403).send(
|
|
||||||
'<p style="font-family:sans-serif;padding:2rem">' +
|
|
||||||
'Недействительный CSRF-токен. ' +
|
|
||||||
'<a href="javascript:history.back()">Вернитесь назад</a> и повторите действие.' +
|
|
||||||
'</p>'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.error('Unhandled error:', err);
|
console.error('Unhandled error:', err);
|
||||||
res.status(500).send('Внутренняя ошибка сервера');
|
res.status(500).send('Внутренняя ошибка сервера');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ function createAdminRouter({ q }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/audit — журнал действий (admin only)
|
||||||
|
// ?company=<id> — фильтр по компании (опционально)
|
||||||
|
router.get('/audit', apiRequireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
|
||||||
|
const [rows, companies] = await Promise.all([
|
||||||
|
q.getAudit(companyId && Number.isFinite(companyId) ? companyId : null),
|
||||||
|
q.getAllCompanies(),
|
||||||
|
]);
|
||||||
|
res.json({ rows, companies });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { aggregateCIDRs } = require('../../validators');
|
||||||
|
|
||||||
function createEntriesRouter({ q }) {
|
function createEntriesRouter({ q }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const json = express.json({ limit: '32kb' });
|
const json = express.json({ limit: '32kb' });
|
||||||
|
|
||||||
// ── Хелпер: маппинг сообщений ошибок на HTTP-коды ───────────────────────
|
// Маппинг сообщений ошибок на HTTP-коды
|
||||||
function handleQueryError(e, res) {
|
function handleQueryError(e, res) {
|
||||||
const msg = e.message || '';
|
const msg = e.message || '';
|
||||||
if (
|
if (
|
||||||
@@ -22,12 +23,54 @@ function createEntriesRouter({ q }) {
|
|||||||
return res.status(500).json({ error: msg });
|
return res.status(500).json({ error: msg });
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/v1/entries — список записей текущей компании
|
// Определить компанию из запроса.
|
||||||
|
// Admin с ?company=<id> → компания по ID.
|
||||||
|
// Иначе → компания пользователя (UPSERT).
|
||||||
|
async function resolveCompany(req) {
|
||||||
|
if (req.user.isAdmin && req.query.company) {
|
||||||
|
const id = parseInt(req.query.company, 10);
|
||||||
|
if (!Number.isFinite(id) || id <= 0) {
|
||||||
|
throw Object.assign(new Error('Invalid company id'), { status: 400 });
|
||||||
|
}
|
||||||
|
const c = await q.getCompanyById(id);
|
||||||
|
if (!c) throw Object.assign(new Error('Company not found'), { status: 404 });
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
return q.getOrCreateCompany(req.user.clientId, req.user.companyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/entries — список записей + лимит
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const company = await resolveCompany(req);
|
||||||
|
const [entries, limit] = await Promise.all([
|
||||||
|
q.listEntries(company.id),
|
||||||
|
q.getLimit(company),
|
||||||
|
]);
|
||||||
|
res.json({ entries, limit, used: entries.length });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(e.status || 500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/entries/export — агрегированный список CIDR (text/plain)
|
||||||
|
// Доступен всем аутентифицированным пользователям.
|
||||||
|
// Admin: все компании или ?company=<id>. User: только своя компания.
|
||||||
|
router.get('/export', async (req, res) => {
|
||||||
|
try {
|
||||||
|
let companyId = null;
|
||||||
|
if (!req.user.isAdmin) {
|
||||||
const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
|
const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
|
||||||
const entries = await q.listEntries(company.id);
|
companyId = company.id;
|
||||||
res.json({ entries });
|
} else if (req.query.company) {
|
||||||
|
const id = parseInt(req.query.company, 10);
|
||||||
|
if (Number.isFinite(id) && id > 0) companyId = id;
|
||||||
|
}
|
||||||
|
const cidrs = await q.getExportCIDRs(companyId);
|
||||||
|
const aggregated = aggregateCIDRs(cidrs);
|
||||||
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', 'inline; filename="whitelist.txt"');
|
||||||
|
res.send(aggregated.join('\n') + (aggregated.length ? '\n' : ''));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message });
|
res.status(500).json({ error: e.message });
|
||||||
}
|
}
|
||||||
@@ -38,10 +81,11 @@ function createEntriesRouter({ q }) {
|
|||||||
const { value, comment } = req.body || {};
|
const { value, comment } = req.body || {};
|
||||||
if (!value) return res.status(400).json({ error: 'value required' });
|
if (!value) return res.status(400).json({ error: 'value required' });
|
||||||
try {
|
try {
|
||||||
const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
|
const company = await resolveCompany(req);
|
||||||
const { entry, wasNormalized } = await q.createEntry(company.id, value, comment || '', req.user.email);
|
const { entry, wasNormalized } = await q.createEntry(company.id, value, comment || '', req.user.email);
|
||||||
res.status(201).json({ entry, wasNormalized });
|
res.status(201).json({ entry, wasNormalized });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.status) return res.status(e.status).json({ error: e.message });
|
||||||
handleQueryError(e, res);
|
handleQueryError(e, res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -51,10 +95,11 @@ function createEntriesRouter({ q }) {
|
|||||||
const { value, comment } = req.body || {};
|
const { value, comment } = req.body || {};
|
||||||
if (!value) return res.status(400).json({ error: 'value required' });
|
if (!value) return res.status(400).json({ error: 'value required' });
|
||||||
try {
|
try {
|
||||||
const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
|
const company = await resolveCompany(req);
|
||||||
const { entry, wasNormalized } = await q.updateEntry(req.params.id, company.id, value, comment ?? '', req.user.email);
|
const { entry, wasNormalized } = await q.updateEntry(req.params.id, company.id, value, comment ?? '', req.user.email);
|
||||||
res.json({ entry, wasNormalized });
|
res.json({ entry, wasNormalized });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.status) return res.status(e.status).json({ error: e.message });
|
||||||
handleQueryError(e, res);
|
handleQueryError(e, res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -62,10 +107,11 @@ function createEntriesRouter({ q }) {
|
|||||||
// DELETE /api/v1/entries/:id — удалить запись
|
// DELETE /api/v1/entries/:id — удалить запись
|
||||||
router.delete('/:id', async (req, res) => {
|
router.delete('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
|
const company = await resolveCompany(req);
|
||||||
await q.deleteEntry(req.params.id, company.id, req.user.email);
|
await q.deleteEntry(req.params.id, company.id, req.user.email);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.status) return res.status(e.status).json({ error: e.message });
|
||||||
handleQueryError(e, res);
|
handleQueryError(e, res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ui/api-client.js — HTTP-клиент к /api/v1/*.
|
||||||
|
*
|
||||||
|
* UI-роуты не ходят напрямую в БД — только сюда.
|
||||||
|
* Токен берётся из req.session.token (установлен на /login).
|
||||||
|
*
|
||||||
|
* Все ошибки пробрасываются как Error с понятным message.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const API_BASE = process.env.API_BASE || 'http://localhost:' + (process.env.PORT || 3000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполнить запрос к API.
|
||||||
|
* @param {string} method — GET, POST, PATCH, DELETE
|
||||||
|
* @param {string} path — /api/v1/entries
|
||||||
|
* @param {string} token — Bearer token
|
||||||
|
* @param {object} [body] — JSON body (для POST/PATCH)
|
||||||
|
* @returns {Promise<{status: number, data: any}>}
|
||||||
|
*/
|
||||||
|
function apiRequest(method, path, token, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(API_BASE + path);
|
||||||
|
const lib = url.protocol === 'https:' ? https : http;
|
||||||
|
const bodyStr = body ? JSON.stringify(body) : null;
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (bodyStr) {
|
||||||
|
opts.headers['Content-Type'] = 'application/json';
|
||||||
|
opts.headers['Content-Length'] = Buffer.byteLength(bodyStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = lib.request(opts, (res) => {
|
||||||
|
let raw = '';
|
||||||
|
res.on('data', c => { raw += c; });
|
||||||
|
res.on('end', () => {
|
||||||
|
const ct = res.headers['content-type'] || '';
|
||||||
|
const data = ct.includes('application/json') ? JSON.parse(raw) : raw;
|
||||||
|
resolve({ status: res.statusCode, data });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => { req.destroy(); reject(new Error('API timeout')); });
|
||||||
|
if (bodyStr) req.write(bodyStr);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хелперы — один вызов на метод
|
||||||
|
const api = {
|
||||||
|
get: (path, token) => apiRequest('GET', path, token),
|
||||||
|
post: (path, token, body) => apiRequest('POST', path, token, body),
|
||||||
|
patch: (path, token, body) => apiRequest('PATCH', path, token, body),
|
||||||
|
delete: (path, token) => apiRequest('DELETE', path, token),
|
||||||
|
|
||||||
|
// Достать Bearer из сессии
|
||||||
|
token: (req) => req.session && req.session.token,
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = api;
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ui/index.js — UI-слой: SSR через EJS, данные из /api/v1/*.
|
||||||
|
*
|
||||||
|
* createUiRouter({ auth, MOCK_USERS, authLimiter }) → Express Router.
|
||||||
|
*
|
||||||
|
* Монтируется в server.js ПОСЛЕ /api/v1/.
|
||||||
|
*
|
||||||
|
* Middleware:
|
||||||
|
* 1. requireToken — если нет session.token → redirect /login
|
||||||
|
* 2. resolveUser — декодирует payload из токена → req.user
|
||||||
|
* (без проверки подписи; API валидирует при каждом запросе)
|
||||||
|
*
|
||||||
|
* Если API возвращает 401 → сессия уничтожается → redirect /login.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { Router } = require('express');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const { createRouter: createAuthRouter } = require('./routes/auth');
|
||||||
|
const { createRouter: createEntriesRouter } = require('./routes/entries');
|
||||||
|
const { createRouter: createAdminRouter } = require('./routes/admin');
|
||||||
|
const { createRouter: createExportRouter } = require('./routes/export');
|
||||||
|
const api = require('./api-client');
|
||||||
|
|
||||||
|
const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112';
|
||||||
|
|
||||||
|
function createUiRouter({ auth, MOCK_USERS, authLimiter }) {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ── Публичные маршруты: /login, /login-token, /logout ────────────────────
|
||||||
|
router.use('/', createAuthRouter({ auth, MOCK_USERS, authLimiter }));
|
||||||
|
|
||||||
|
// ── requireToken: все маршруты ниже требуют session.token ────────────────
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
const token = api.token(req);
|
||||||
|
if (!token) {
|
||||||
|
return res.redirect('/login?returnTo=' + encodeURIComponent(req.originalUrl));
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── resolveUser: payload → req.user ──────────────────────────────────────
|
||||||
|
// Подпись не проверяем здесь — API проверяет при каждом запросе.
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
if (req.user) return next();
|
||||||
|
try {
|
||||||
|
const payload = jwt.decode(api.token(req));
|
||||||
|
if (!payload) throw new Error('empty payload');
|
||||||
|
const clientId = payload.ClientID || payload.clientId || payload.sub || '';
|
||||||
|
req.user = {
|
||||||
|
clientId,
|
||||||
|
companyId: payload.company_id || payload.companyId || null,
|
||||||
|
companyName: payload.company_name || payload.companyName || clientId,
|
||||||
|
email: payload.email || clientId + '@unknown',
|
||||||
|
isAdmin: clientId === ADMIN_CLIENT_ID,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
req.session.destroy(() =>
|
||||||
|
res.redirect('/login?error=' + encodeURIComponent('Токен недействителен, войдите заново'))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Основные маршруты (все за requireToken + resolveUser) ─────────────────
|
||||||
|
router.use('/', createEntriesRouter());
|
||||||
|
router.use('/', createExportRouter());
|
||||||
|
router.use('/', createAdminRouter());
|
||||||
|
|
||||||
|
// ── 404 ───────────────────────────────────────────────────────────────────
|
||||||
|
router.use((req, res) => {
|
||||||
|
res.status(404).render('error', { status: 404, message: 'Страница не найдена' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Общий обработчик ошибок ───────────────────────────────────────────────
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
router.use((err, req, res, next) => {
|
||||||
|
console.error('[UI] Error:', err);
|
||||||
|
res.status(500).render('error', { status: 500, message: 'Внутренняя ошибка сервера' });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createUiRouter };
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Ошибка <%= status %> — IP WhiteList</title>
|
||||||
|
<link rel="icon" href="/favicon.png" type="image/png">
|
||||||
|
<style>
|
||||||
|
:root { --bg: #f5f5f5; --card: #fff; --text: #1a1a1a; --muted: #6b7280; --border: #d1d5db; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg); color: var(--text); font-size: 14px;
|
||||||
|
display: flex; align-items: center; justify-content: center; min-height: 100vh;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.06); padding: 2.5rem 3rem; text-align: center;
|
||||||
|
}
|
||||||
|
.status { font-size: 3rem; font-weight: 700; color: #dc2626; line-height: 1; }
|
||||||
|
.message { margin: 1rem 0 1.5rem; color: var(--muted); max-width: 340px; }
|
||||||
|
a { color: #2563eb; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="status"><%= status %></div>
|
||||||
|
<p class="message"><%= message %></p>
|
||||||
|
<a href="/">← На главную</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Вход — IP WhiteList</title>
|
||||||
|
<link rel="icon" href="/favicon.png" type="image/png">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f5f5f5; --card: #ffffff; --text: #1a1a1a; --muted: #6b7280;
|
||||||
|
--border: #d1d5db; --blue: #2563eb; --blue-h: #1d4ed8;
|
||||||
|
--amber: #d97706; --amber-bg: #fef3c7; --amber-border: #fde68a;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
|
||||||
|
display: flex; align-items: center; justify-content: center; min-height: 100vh;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.06); width: 440px; padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.2rem; margin-bottom: .25rem; }
|
||||||
|
.sub { color: var(--muted); font-size: .85rem; margin-bottom: 1.25rem; }
|
||||||
|
.warn {
|
||||||
|
padding: .75rem 1rem; background: var(--amber-bg); border: 1px solid var(--amber-border);
|
||||||
|
border-radius: 8px; font-size: .82rem; color: var(--amber); margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.warn strong { display: block; margin-bottom: .25rem; font-size: .9rem; }
|
||||||
|
hr { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; }
|
||||||
|
.section-title {
|
||||||
|
font-size: .75rem; font-weight: 600; color: var(--muted);
|
||||||
|
text-transform: uppercase; letter-spacing: .5px; margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
.field { display: flex; flex-direction: column; gap: .25rem; margin-bottom: .85rem; }
|
||||||
|
.field label { font-size: .8rem; font-weight: 500; color: var(--muted); text-transform: uppercase; }
|
||||||
|
.field select, .field input[type=text], .field textarea {
|
||||||
|
padding: .5rem .75rem; border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
font-size: .9rem; outline: none; background: #fff; width: 100%;
|
||||||
|
}
|
||||||
|
.field select:focus, .field input:focus, .field textarea:focus {
|
||||||
|
border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.1);
|
||||||
|
}
|
||||||
|
.field textarea { resize: vertical; min-height: 60px; font-family: monospace; font-size: .82rem; }
|
||||||
|
.btn {
|
||||||
|
width: 100%; padding: .6rem; border: none; border-radius: 6px; font-size: .9rem;
|
||||||
|
font-weight: 500; cursor: pointer; background: var(--blue); color: #fff;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--blue-h); }
|
||||||
|
.error { color: #dc2626; font-size: .85rem; margin-bottom: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h1>IP WhiteList</h1>
|
||||||
|
<p class="sub">Вход в систему</p>
|
||||||
|
|
||||||
|
<% if (error) { %><div class="error"><%= error %></div><% } %>
|
||||||
|
|
||||||
|
<% if (devMode && MOCK_USERS && MOCK_USERS.length > 0) { %>
|
||||||
|
<%# ── Блок mock-пользователей (только DEV_MODE=true) ── %>
|
||||||
|
<div class="warn">
|
||||||
|
<strong>⚠ ТЕСТОВЫЙ РЕЖИМ</strong>
|
||||||
|
Вход без проверки пароля. Только для разработки.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-title">Быстрый вход (mock)</p>
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<input type="hidden" name="returnTo" value="<%= returnTo || '/' %>">
|
||||||
|
<div class="field">
|
||||||
|
<label>Пользователь</label>
|
||||||
|
<select name="clientId" required>
|
||||||
|
<option value="">— выберите —</option>
|
||||||
|
<% MOCK_USERS.forEach(u => { %>
|
||||||
|
<option value="<%= u.clientId %>"><%= u.clientId %> — <%= u.companyName || u.clientId %> (<%= u.isAdmin ? 'admin' : 'user' %>)</option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn" type="submit">Войти как mock-пользователь</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<%# ── Блок вставки Bearer токена (всегда виден) ── %>
|
||||||
|
<p class="section-title">Войти с Bearer токеном</p>
|
||||||
|
<form method="POST" action="/login-token">
|
||||||
|
<input type="hidden" name="returnTo" value="<%= returnTo || '/' %>">
|
||||||
|
<div class="field">
|
||||||
|
<label>Bearer токен</label>
|
||||||
|
<textarea name="token" placeholder="eyJ..." rows="3" autocomplete="off"></textarea>
|
||||||
|
</div>
|
||||||
|
<button class="btn" type="submit">Войти по токену</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer style="position:fixed;bottom:.75rem;right:1.5rem;font-size:.72rem;color:var(--muted);">
|
||||||
|
v<%= appVersion %>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user